@multicomma/arkcn 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/README.md +78 -0
- package/bin/arkcn.mjs +78 -0
- package/cli/commands.mjs +251 -0
- package/cli/project.mjs +168 -0
- package/cli/registry.mjs +50 -0
- package/cli/ui.mjs +46 -0
- package/mcp/server.mjs +149 -0
- package/package.json +201 -0
- package/registry/base.css +680 -0
- package/registry/index.json +1833 -0
- package/registry/items/accordion.json +18 -0
- package/registry/items/activity-feed.json +18 -0
- package/registry/items/alert-dialog.json +18 -0
- package/registry/items/alert.json +17 -0
- package/registry/items/angle-slider.json +17 -0
- package/registry/items/app-shell.json +28 -0
- package/registry/items/aspect-ratio.json +13 -0
- package/registry/items/attachment.json +19 -0
- package/registry/items/avatar.json +17 -0
- package/registry/items/badge.json +18 -0
- package/registry/items/breadcrumb.json +18 -0
- package/registry/items/bubble.json +18 -0
- package/registry/items/button-group.json +19 -0
- package/registry/items/button.json +18 -0
- package/registry/items/calendar.json +19 -0
- package/registry/items/canvas.json +24 -0
- package/registry/items/card.json +15 -0
- package/registry/items/carousel.json +19 -0
- package/registry/items/cascader.json +23 -0
- package/registry/items/chart.json +17 -0
- package/registry/items/chat.json +23 -0
- package/registry/items/checkbox.json +18 -0
- package/registry/items/clipboard.json +19 -0
- package/registry/items/collapsible.json +15 -0
- package/registry/items/color-picker.json +19 -0
- package/registry/items/combobox.json +20 -0
- package/registry/items/command.json +21 -0
- package/registry/items/comment-thread.json +25 -0
- package/registry/items/context-menu.json +18 -0
- package/registry/items/controllable.json +14 -0
- package/registry/items/data-grid.json +24 -0
- package/registry/items/data-table-adapter.json +14 -0
- package/registry/items/data-table.json +32 -0
- package/registry/items/date-input.json +17 -0
- package/registry/items/dialog.json +19 -0
- package/registry/items/direction.json +13 -0
- package/registry/items/drag-preview.json +16 -0
- package/registry/items/drawer.json +18 -0
- package/registry/items/dropdown-menu.json +18 -0
- package/registry/items/editable.json +17 -0
- package/registry/items/empty.json +17 -0
- package/registry/items/field.json +19 -0
- package/registry/items/file-upload.json +19 -0
- package/registry/items/floating-panel.json +19 -0
- package/registry/items/floating-toolbar.json +16 -0
- package/registry/items/form.json +14 -0
- package/registry/items/format.json +15 -0
- package/registry/items/gantt.json +22 -0
- package/registry/items/history.json +14 -0
- package/registry/items/hotkeys.json +18 -0
- package/registry/items/hover-card.json +17 -0
- package/registry/items/image-cropper.json +17 -0
- package/registry/items/input-group.json +20 -0
- package/registry/items/input-otp.json +18 -0
- package/registry/items/input.json +15 -0
- package/registry/items/item.json +19 -0
- package/registry/items/json-tree-view.json +17 -0
- package/registry/items/kanban.json +26 -0
- package/registry/items/kbd.json +15 -0
- package/registry/items/label.json +15 -0
- package/registry/items/live-region.json +16 -0
- package/registry/items/marker.json +18 -0
- package/registry/items/marquee.json +17 -0
- package/registry/items/menubar.json +19 -0
- package/registry/items/message.json +15 -0
- package/registry/items/navigation-menu.json +19 -0
- package/registry/items/node-graph.json +22 -0
- package/registry/items/number-input.json +18 -0
- package/registry/items/pagination.json +18 -0
- package/registry/items/password-input.json +18 -0
- package/registry/items/popconfirm.json +20 -0
- package/registry/items/popover.json +18 -0
- package/registry/items/progress.json +17 -0
- package/registry/items/qr-code.json +17 -0
- package/registry/items/query-builder.json +23 -0
- package/registry/items/radio-group.json +17 -0
- package/registry/items/rating-group.json +18 -0
- package/registry/items/resizable.json +17 -0
- package/registry/items/rich-text-editor.json +37 -0
- package/registry/items/row-layout.json +15 -0
- package/registry/items/scheduler.json +23 -0
- package/registry/items/scroll-area.json +17 -0
- package/registry/items/segment-group.json +17 -0
- package/registry/items/select.json +19 -0
- package/registry/items/separator.json +15 -0
- package/registry/items/sheet.json +19 -0
- package/registry/items/sidebar.json +27 -0
- package/registry/items/signature-pad.json +19 -0
- package/registry/items/skeleton.json +15 -0
- package/registry/items/slider.json +17 -0
- package/registry/items/sonner.json +17 -0
- package/registry/items/spinner.json +17 -0
- package/registry/items/steps.json +19 -0
- package/registry/items/swap.json +17 -0
- package/registry/items/switch.json +17 -0
- package/registry/items/table.json +16 -0
- package/registry/items/tabs.json +18 -0
- package/registry/items/tags-input.json +18 -0
- package/registry/items/textarea.json +15 -0
- package/registry/items/time.json +14 -0
- package/registry/items/timer.json +18 -0
- package/registry/items/toc.json +17 -0
- package/registry/items/toggle-group.json +19 -0
- package/registry/items/toggle.json +18 -0
- package/registry/items/tooltip.json +17 -0
- package/registry/items/tour.json +19 -0
- package/registry/items/transfer-list.json +24 -0
- package/registry/items/tree-select.json +24 -0
- package/registry/items/tree-view.json +18 -0
- package/registry/items/use-mobile.json +13 -0
- package/registry/items/utils.json +17 -0
- package/registry/items/virtual-list.json +16 -0
- package/src/components/ui/accordion.tsx +111 -0
- package/src/components/ui/activity-feed.tsx +228 -0
- package/src/components/ui/alert-dialog.tsx +204 -0
- package/src/components/ui/alert.tsx +55 -0
- package/src/components/ui/angle-slider.tsx +117 -0
- package/src/components/ui/app-shell.tsx +985 -0
- package/src/components/ui/aspect-ratio.tsx +35 -0
- package/src/components/ui/attachment.tsx +193 -0
- package/src/components/ui/avatar.tsx +94 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/breadcrumb.tsx +93 -0
- package/src/components/ui/bubble.tsx +119 -0
- package/src/components/ui/button-group.tsx +79 -0
- package/src/components/ui/button.tsx +65 -0
- package/src/components/ui/calendar.tsx +172 -0
- package/src/components/ui/canvas.tsx +723 -0
- package/src/components/ui/card.tsx +69 -0
- package/src/components/ui/carousel.tsx +228 -0
- package/src/components/ui/cascader.tsx +754 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/chat.tsx +544 -0
- package/src/components/ui/checkbox.tsx +94 -0
- package/src/components/ui/clipboard.tsx +94 -0
- package/src/components/ui/collapsible.tsx +32 -0
- package/src/components/ui/color-picker.tsx +441 -0
- package/src/components/ui/combobox.tsx +400 -0
- package/src/components/ui/command.tsx +292 -0
- package/src/components/ui/comment-thread.tsx +923 -0
- package/src/components/ui/context-menu.tsx +331 -0
- package/src/components/ui/data-grid.tsx +1227 -0
- package/src/components/ui/data-table.tsx +993 -0
- package/src/components/ui/date-input.tsx +103 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +32 -0
- package/src/components/ui/drawer.tsx +200 -0
- package/src/components/ui/dropdown-menu.tsx +329 -0
- package/src/components/ui/editable.tsx +90 -0
- package/src/components/ui/empty.tsx +86 -0
- package/src/components/ui/field.tsx +218 -0
- package/src/components/ui/file-upload.tsx +204 -0
- package/src/components/ui/floating-panel.tsx +199 -0
- package/src/components/ui/floating-toolbar.tsx +79 -0
- package/src/components/ui/format.tsx +22 -0
- package/src/components/ui/gantt.tsx +950 -0
- package/src/components/ui/hotkeys.tsx +340 -0
- package/src/components/ui/hover-card.tsx +83 -0
- package/src/components/ui/image-cropper.tsx +100 -0
- package/src/components/ui/input-group.tsx +133 -0
- package/src/components/ui/input-otp.tsx +82 -0
- package/src/components/ui/input.tsx +18 -0
- package/src/components/ui/item.tsx +174 -0
- package/src/components/ui/json-tree-view.tsx +37 -0
- package/src/components/ui/kanban.tsx +884 -0
- package/src/components/ui/kbd.tsx +20 -0
- package/src/components/ui/label.tsx +30 -0
- package/src/components/ui/live-region.tsx +54 -0
- package/src/components/ui/marker.tsx +66 -0
- package/src/components/ui/marquee.tsx +61 -0
- package/src/components/ui/menubar.tsx +456 -0
- package/src/components/ui/message.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +202 -0
- package/src/components/ui/node-graph.tsx +1723 -0
- package/src/components/ui/number-input.tsx +148 -0
- package/src/components/ui/pagination.tsx +88 -0
- package/src/components/ui/password-input.tsx +104 -0
- package/src/components/ui/popconfirm.tsx +149 -0
- package/src/components/ui/popover.tsx +126 -0
- package/src/components/ui/progress.tsx +114 -0
- package/src/components/ui/qr-code.tsx +51 -0
- package/src/components/ui/query-builder.tsx +938 -0
- package/src/components/ui/radio-group.tsx +108 -0
- package/src/components/ui/rating-group.tsx +91 -0
- package/src/components/ui/resizable.tsx +41 -0
- package/src/components/ui/rich-text-editor.tsx +845 -0
- package/src/components/ui/scheduler.tsx +1164 -0
- package/src/components/ui/scroll-area.tsx +82 -0
- package/src/components/ui/segment-group.tsx +95 -0
- package/src/components/ui/select.tsx +354 -0
- package/src/components/ui/separator.tsx +32 -0
- package/src/components/ui/sheet.tsx +130 -0
- package/src/components/ui/sidebar.tsx +665 -0
- package/src/components/ui/signature-pad.tsx +112 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +183 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/steps.tsx +152 -0
- package/src/components/ui/swap.tsx +33 -0
- package/src/components/ui/switch.tsx +83 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +100 -0
- package/src/components/ui/tags-input.tsx +153 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/timer.tsx +67 -0
- package/src/components/ui/toc.tsx +64 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +46 -0
- package/src/components/ui/tooltip.tsx +113 -0
- package/src/components/ui/tour.tsx +168 -0
- package/src/components/ui/transfer-list.tsx +700 -0
- package/src/components/ui/tree-select.tsx +638 -0
- package/src/components/ui/tree-view.tsx +217 -0
- package/src/components/ui/virtual-list.tsx +343 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/lib/controllable.ts +31 -0
- package/src/lib/data-table-adapter.ts +67 -0
- package/src/lib/drag-preview.ts +39 -0
- package/src/lib/form.ts +39 -0
- package/src/lib/history.ts +55 -0
- package/src/lib/row-layout.ts +81 -0
- package/src/lib/table.ts +483 -0
- package/src/lib/time.ts +37 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/arkcn.css +764 -0
- package/src/styles/index.css +3 -0
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import {
|
|
3
|
+
addDays,
|
|
4
|
+
differenceInCalendarDays,
|
|
5
|
+
eachDayOfInterval,
|
|
6
|
+
eachMonthOfInterval,
|
|
7
|
+
eachWeekOfInterval,
|
|
8
|
+
endOfMonth,
|
|
9
|
+
endOfWeek,
|
|
10
|
+
format,
|
|
11
|
+
isSameDay,
|
|
12
|
+
isWeekend,
|
|
13
|
+
startOfDay,
|
|
14
|
+
startOfMonth,
|
|
15
|
+
startOfWeek,
|
|
16
|
+
} from "date-fns"
|
|
17
|
+
import { CalendarIcon, MinusIcon, PlusIcon } from "lucide-react"
|
|
18
|
+
import { Button } from "@/components/ui/button"
|
|
19
|
+
import { cn } from "@/lib/utils"
|
|
20
|
+
import { useControllable } from "@/lib/controllable"
|
|
21
|
+
import { LiveRegion, useLiveRegion } from "@/components/ui/live-region"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Gantt — a compositional, data-agnostic timeline. The consumer owns rows,
|
|
25
|
+
* bars and dependencies and renders them with the parts; the root owns only
|
|
26
|
+
* the time scale (`start`/`end`, `dayWidth`, header `scale`), the drag/resize
|
|
27
|
+
* interaction and keyboard moves, and reports `onBarChange({ id, start, end })`.
|
|
28
|
+
* Dates snap to whole days.
|
|
29
|
+
*
|
|
30
|
+
* Anatomy:
|
|
31
|
+
* Gantt { start, end, dayWidth, scale, onBarChange }
|
|
32
|
+
* GanttControls > GanttZoomOutTrigger / GanttZoomInTrigger / GanttTodayTrigger
|
|
33
|
+
* GanttViewport single scroll container (x and y)
|
|
34
|
+
* GanttHeader sticky two-tier date header (+ GanttHeaderCorner over the sidebar)
|
|
35
|
+
* GanttBody
|
|
36
|
+
* GanttRow value=… sidebar cell (GanttRowLabel, sticky) + track
|
|
37
|
+
* GanttBar value=… start end progress > GanttBarLabel, GanttBarResizeHandle side=…
|
|
38
|
+
* GanttMilestone value=… date
|
|
39
|
+
* GanttDependencies svg arrows between registered bars
|
|
40
|
+
* GanttToday vertical marker
|
|
41
|
+
* GanttEmpty
|
|
42
|
+
*
|
|
43
|
+
* Keyboard on a bar: Space/Enter picks up, ←/→ move a day, Shift+←/→ change
|
|
44
|
+
* the end, Alt+←/→ change the start, Space/Enter drops, Escape cancels.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
type GanttScale = "day" | "week" | "month"
|
|
48
|
+
type GanttBarChange = { id: string; start: Date; end: Date }
|
|
49
|
+
type BarRegistration = { id: string; el: HTMLElement | null; start: Date; end: Date; milestone?: boolean }
|
|
50
|
+
|
|
51
|
+
/** Registered bars, outside React state so registering never re-renders the root. */
|
|
52
|
+
function createBarStore() {
|
|
53
|
+
const bars = new Map<string, BarRegistration>()
|
|
54
|
+
const listeners = new Set<() => void>()
|
|
55
|
+
let version = 0
|
|
56
|
+
const notify = () => {
|
|
57
|
+
version++
|
|
58
|
+
listeners.forEach((l) => l())
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
bars,
|
|
62
|
+
subscribe: (l: () => void) => {
|
|
63
|
+
listeners.add(l)
|
|
64
|
+
return () => {
|
|
65
|
+
listeners.delete(l)
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
getVersion: () => version,
|
|
69
|
+
register(bar: BarRegistration) {
|
|
70
|
+
bars.set(bar.id, bar)
|
|
71
|
+
notify()
|
|
72
|
+
return () => {
|
|
73
|
+
if (bars.get(bar.id) === bar) bars.delete(bar.id)
|
|
74
|
+
notify()
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
type BarStore = ReturnType<typeof createBarStore>
|
|
80
|
+
|
|
81
|
+
type Interaction = {
|
|
82
|
+
id: string
|
|
83
|
+
type: "move" | "resize-start" | "resize-end"
|
|
84
|
+
start: Date
|
|
85
|
+
end: Date
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type GanttContextValue = {
|
|
89
|
+
start: Date
|
|
90
|
+
end: Date
|
|
91
|
+
days: number
|
|
92
|
+
dayWidth: number
|
|
93
|
+
rowHeight: number
|
|
94
|
+
sidebarWidth: number
|
|
95
|
+
scale: GanttScale
|
|
96
|
+
editable: boolean
|
|
97
|
+
interaction: Interaction | null
|
|
98
|
+
grabbed: string | null
|
|
99
|
+
viewportRef: React.RefObject<HTMLDivElement | null>
|
|
100
|
+
xOf: (date: Date) => number
|
|
101
|
+
dateAt: (x: number) => Date
|
|
102
|
+
registerBar: (bar: BarRegistration) => () => void
|
|
103
|
+
barStore: BarStore
|
|
104
|
+
startInteraction: (bar: BarRegistration, type: Interaction["type"], event: React.PointerEvent) => void
|
|
105
|
+
grab: (id: string | null) => void
|
|
106
|
+
cancelGrab: () => void
|
|
107
|
+
nudge: (bar: BarRegistration, deltaDays: number, part: "both" | "start" | "end") => void
|
|
108
|
+
zoomIn: () => void
|
|
109
|
+
zoomOut: () => void
|
|
110
|
+
scrollToDate: (date: Date, options?: { behavior?: ScrollBehavior }) => void
|
|
111
|
+
announce: (message: string) => void
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const GanttContext = React.createContext<GanttContextValue | null>(null)
|
|
115
|
+
const RowContext = React.createContext<{ value: string } | null>(null)
|
|
116
|
+
|
|
117
|
+
function useGantt() {
|
|
118
|
+
const ctx = React.useContext(GanttContext)
|
|
119
|
+
if (!ctx) throw new Error("Gantt parts must be used within <Gantt>")
|
|
120
|
+
return ctx
|
|
121
|
+
}
|
|
122
|
+
function useGanttRow() {
|
|
123
|
+
const ctx = React.useContext(RowContext)
|
|
124
|
+
if (!ctx) throw new Error("Row parts must be used within <GanttRow>")
|
|
125
|
+
return ctx
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
|
|
129
|
+
|
|
130
|
+
/* ---------------------------------------------------------------------------
|
|
131
|
+
* Root
|
|
132
|
+
* ------------------------------------------------------------------------- */
|
|
133
|
+
|
|
134
|
+
function Gantt({
|
|
135
|
+
start: startProp,
|
|
136
|
+
end: endProp,
|
|
137
|
+
dayWidth: dayWidthProp,
|
|
138
|
+
defaultDayWidth = 40,
|
|
139
|
+
onDayWidthChange,
|
|
140
|
+
minDayWidth = 8,
|
|
141
|
+
maxDayWidth = 160,
|
|
142
|
+
scale: scaleProp,
|
|
143
|
+
rowHeight = 40,
|
|
144
|
+
sidebarWidth = 240,
|
|
145
|
+
editable = true,
|
|
146
|
+
onBarChange,
|
|
147
|
+
className,
|
|
148
|
+
children,
|
|
149
|
+
...props
|
|
150
|
+
}: Omit<React.ComponentProps<"div">, "onChange"> & {
|
|
151
|
+
start: Date
|
|
152
|
+
end: Date
|
|
153
|
+
/** Pixels per day; controllable. Zooming changes it. */
|
|
154
|
+
dayWidth?: number
|
|
155
|
+
defaultDayWidth?: number
|
|
156
|
+
onDayWidthChange?: (dayWidth: number) => void
|
|
157
|
+
minDayWidth?: number
|
|
158
|
+
maxDayWidth?: number
|
|
159
|
+
/** Header tiers. Defaults from `dayWidth`: month below 16px, week below 32px, else day. */
|
|
160
|
+
scale?: GanttScale
|
|
161
|
+
rowHeight?: number
|
|
162
|
+
sidebarWidth?: number
|
|
163
|
+
editable?: boolean
|
|
164
|
+
onBarChange?: (change: GanttBarChange) => void
|
|
165
|
+
}) {
|
|
166
|
+
const viewportRef = React.useRef<HTMLDivElement>(null)
|
|
167
|
+
const start = React.useMemo(() => startOfDay(startProp), [startProp])
|
|
168
|
+
const end = React.useMemo(() => startOfDay(endProp), [endProp])
|
|
169
|
+
const days = Math.max(1, differenceInCalendarDays(end, start) + 1)
|
|
170
|
+
|
|
171
|
+
const [dayWidth, setDayWidthState] = useControllable(dayWidthProp, defaultDayWidth, onDayWidthChange)
|
|
172
|
+
const setDayWidth = React.useCallback(
|
|
173
|
+
(next: number) => setDayWidthState(clamp(next, minDayWidth, maxDayWidth)),
|
|
174
|
+
[setDayWidthState, minDayWidth, maxDayWidth]
|
|
175
|
+
)
|
|
176
|
+
const scale: GanttScale = scaleProp ?? (dayWidth < 16 ? "month" : dayWidth < 32 ? "week" : "day")
|
|
177
|
+
|
|
178
|
+
const xOf = React.useCallback(
|
|
179
|
+
(date: Date) => differenceInCalendarDays(startOfDay(date), start) * dayWidth,
|
|
180
|
+
[start, dayWidth]
|
|
181
|
+
)
|
|
182
|
+
const dateAt = React.useCallback((x: number) => addDays(start, Math.round(x / dayWidth)), [start, dayWidth])
|
|
183
|
+
|
|
184
|
+
/* Bar registry for dependency lines */
|
|
185
|
+
const barStore = React.useMemo(() => createBarStore(), [])
|
|
186
|
+
const registerBar = React.useCallback((bar: BarRegistration) => barStore.register(bar), [barStore])
|
|
187
|
+
|
|
188
|
+
/* Interaction */
|
|
189
|
+
const [interaction, setInteraction] = React.useState<Interaction | null>(null)
|
|
190
|
+
const [grabbed, setGrabbed] = React.useState<string | null>(null)
|
|
191
|
+
const { message: announcement, announce } = useLiveRegion()
|
|
192
|
+
const onBarChangeRef = React.useRef(onBarChange)
|
|
193
|
+
React.useEffect(() => {
|
|
194
|
+
onBarChangeRef.current = onBarChange
|
|
195
|
+
}, [onBarChange])
|
|
196
|
+
const grabOrigin = React.useRef<{ id: string; start: Date; end: Date } | null>(null)
|
|
197
|
+
|
|
198
|
+
const startInteraction = React.useCallback(
|
|
199
|
+
(bar: BarRegistration, type: Interaction["type"], event: React.PointerEvent) => {
|
|
200
|
+
if (!editable) return
|
|
201
|
+
event.preventDefault()
|
|
202
|
+
event.stopPropagation()
|
|
203
|
+
const origin = event.clientX
|
|
204
|
+
let current: Interaction = { id: bar.id, type, start: bar.start, end: bar.end }
|
|
205
|
+
setInteraction(current)
|
|
206
|
+
const move = (e: PointerEvent) => {
|
|
207
|
+
const deltaDays = Math.round((e.clientX - origin) / dayWidth)
|
|
208
|
+
const duration = differenceInCalendarDays(bar.end, bar.start)
|
|
209
|
+
if (type === "move") {
|
|
210
|
+
current = { id: bar.id, type, start: addDays(bar.start, deltaDays), end: addDays(bar.end, deltaDays) }
|
|
211
|
+
} else if (type === "resize-end") {
|
|
212
|
+
current = { id: bar.id, type, start: bar.start, end: addDays(bar.end, Math.max(deltaDays, -duration)) }
|
|
213
|
+
} else {
|
|
214
|
+
current = { id: bar.id, type, start: addDays(bar.start, Math.min(deltaDays, duration)), end: bar.end }
|
|
215
|
+
}
|
|
216
|
+
setInteraction(current)
|
|
217
|
+
}
|
|
218
|
+
const up = () => {
|
|
219
|
+
window.removeEventListener("pointermove", move)
|
|
220
|
+
window.removeEventListener("pointerup", up)
|
|
221
|
+
window.removeEventListener("pointercancel", up)
|
|
222
|
+
setInteraction(null)
|
|
223
|
+
if (!isSameDay(current.start, bar.start) || !isSameDay(current.end, bar.end)) {
|
|
224
|
+
onBarChangeRef.current?.({ id: bar.id, start: current.start, end: current.end })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
window.addEventListener("pointermove", move)
|
|
228
|
+
window.addEventListener("pointerup", up)
|
|
229
|
+
window.addEventListener("pointercancel", up)
|
|
230
|
+
},
|
|
231
|
+
[editable, dayWidth]
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
const grab = React.useCallback(
|
|
235
|
+
(id: string | null) => {
|
|
236
|
+
if (id) {
|
|
237
|
+
const bar = barStore.bars.get(id)
|
|
238
|
+
grabOrigin.current = bar ? { id, start: bar.start, end: bar.end } : null
|
|
239
|
+
setGrabbed(id)
|
|
240
|
+
announce(
|
|
241
|
+
"Picked up. Arrow keys move by a day, Shift changes the end, Alt the start. Space drops, Escape cancels."
|
|
242
|
+
)
|
|
243
|
+
} else {
|
|
244
|
+
setGrabbed(null)
|
|
245
|
+
grabOrigin.current = null
|
|
246
|
+
announce("Dropped.")
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
[barStore, announce]
|
|
250
|
+
)
|
|
251
|
+
const cancelGrab = React.useCallback(() => {
|
|
252
|
+
const origin = grabOrigin.current
|
|
253
|
+
if (origin) onBarChangeRef.current?.(origin)
|
|
254
|
+
setGrabbed(null)
|
|
255
|
+
grabOrigin.current = null
|
|
256
|
+
announce("Cancelled.")
|
|
257
|
+
}, [announce])
|
|
258
|
+
|
|
259
|
+
const nudge = React.useCallback(
|
|
260
|
+
(bar: BarRegistration, deltaDays: number, part: "both" | "start" | "end") => {
|
|
261
|
+
const duration = differenceInCalendarDays(bar.end, bar.start)
|
|
262
|
+
const next =
|
|
263
|
+
part === "both"
|
|
264
|
+
? { start: addDays(bar.start, deltaDays), end: addDays(bar.end, deltaDays) }
|
|
265
|
+
: part === "end"
|
|
266
|
+
? { start: bar.start, end: addDays(bar.end, Math.max(deltaDays, -duration)) }
|
|
267
|
+
: { start: addDays(bar.start, Math.min(deltaDays, duration)), end: bar.end }
|
|
268
|
+
onBarChangeRef.current?.({ id: bar.id, ...next })
|
|
269
|
+
announce(`${format(next.start, "MMM d")} to ${format(next.end, "MMM d")}.`)
|
|
270
|
+
},
|
|
271
|
+
[announce]
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
const scrollToDate = React.useCallback(
|
|
275
|
+
(date: Date, options?: { behavior?: ScrollBehavior }) => {
|
|
276
|
+
const el = viewportRef.current
|
|
277
|
+
if (!el) return
|
|
278
|
+
const x = xOf(date)
|
|
279
|
+
el.scrollTo({
|
|
280
|
+
left: Math.max(0, x - (el.clientWidth - sidebarWidth) / 2),
|
|
281
|
+
behavior: options?.behavior ?? "smooth",
|
|
282
|
+
})
|
|
283
|
+
},
|
|
284
|
+
[xOf, sidebarWidth]
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
/** Zoom keeping the date at the viewport centre in place. */
|
|
288
|
+
const zoomBy = React.useCallback(
|
|
289
|
+
(factor: number) => {
|
|
290
|
+
const el = viewportRef.current
|
|
291
|
+
const centreX = el ? el.scrollLeft + (el.clientWidth - sidebarWidth) / 2 : 0
|
|
292
|
+
const centreDays = centreX / dayWidth
|
|
293
|
+
const next = clamp(dayWidth * factor, minDayWidth, maxDayWidth)
|
|
294
|
+
setDayWidth(next)
|
|
295
|
+
requestAnimationFrame(() => {
|
|
296
|
+
if (el) el.scrollLeft = Math.max(0, centreDays * next - (el.clientWidth - sidebarWidth) / 2)
|
|
297
|
+
})
|
|
298
|
+
},
|
|
299
|
+
[dayWidth, minDayWidth, maxDayWidth, setDayWidth, sidebarWidth]
|
|
300
|
+
)
|
|
301
|
+
const zoomIn = React.useCallback(() => zoomBy(1.5), [zoomBy])
|
|
302
|
+
const zoomOut = React.useCallback(() => zoomBy(1 / 1.5), [zoomBy])
|
|
303
|
+
|
|
304
|
+
const ctx = React.useMemo<GanttContextValue>(
|
|
305
|
+
() => ({
|
|
306
|
+
start,
|
|
307
|
+
end,
|
|
308
|
+
days,
|
|
309
|
+
dayWidth,
|
|
310
|
+
rowHeight,
|
|
311
|
+
sidebarWidth,
|
|
312
|
+
scale,
|
|
313
|
+
editable,
|
|
314
|
+
interaction,
|
|
315
|
+
grabbed,
|
|
316
|
+
viewportRef,
|
|
317
|
+
xOf,
|
|
318
|
+
dateAt,
|
|
319
|
+
registerBar,
|
|
320
|
+
barStore,
|
|
321
|
+
startInteraction,
|
|
322
|
+
grab,
|
|
323
|
+
cancelGrab,
|
|
324
|
+
nudge,
|
|
325
|
+
zoomIn,
|
|
326
|
+
zoomOut,
|
|
327
|
+
scrollToDate,
|
|
328
|
+
announce,
|
|
329
|
+
}),
|
|
330
|
+
[
|
|
331
|
+
start,
|
|
332
|
+
end,
|
|
333
|
+
days,
|
|
334
|
+
dayWidth,
|
|
335
|
+
rowHeight,
|
|
336
|
+
sidebarWidth,
|
|
337
|
+
scale,
|
|
338
|
+
editable,
|
|
339
|
+
interaction,
|
|
340
|
+
grabbed,
|
|
341
|
+
xOf,
|
|
342
|
+
dateAt,
|
|
343
|
+
registerBar,
|
|
344
|
+
barStore,
|
|
345
|
+
startInteraction,
|
|
346
|
+
grab,
|
|
347
|
+
cancelGrab,
|
|
348
|
+
nudge,
|
|
349
|
+
zoomIn,
|
|
350
|
+
zoomOut,
|
|
351
|
+
scrollToDate,
|
|
352
|
+
announce,
|
|
353
|
+
]
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<GanttContext.Provider value={ctx}>
|
|
358
|
+
<div
|
|
359
|
+
data-slot="gantt"
|
|
360
|
+
data-scale={scale}
|
|
361
|
+
data-editable={editable ? "" : undefined}
|
|
362
|
+
data-interacting={interaction?.type}
|
|
363
|
+
className={cn("relative flex min-h-0 flex-1 flex-col", className)}
|
|
364
|
+
style={{ ["--gantt-sidebar" as string]: `${sidebarWidth}px`, ["--gantt-row" as string]: `${rowHeight}px` }}
|
|
365
|
+
{...props}
|
|
366
|
+
>
|
|
367
|
+
{children}
|
|
368
|
+
<LiveRegion data-slot="gantt-live-region" message={announcement} />
|
|
369
|
+
</div>
|
|
370
|
+
</GanttContext.Provider>
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* ---------------------------------------------------------------------------
|
|
375
|
+
* Viewport, header, body
|
|
376
|
+
* ------------------------------------------------------------------------- */
|
|
377
|
+
|
|
378
|
+
function GanttViewport({ className, ...props }: React.ComponentProps<"div">) {
|
|
379
|
+
const { viewportRef } = useGantt()
|
|
380
|
+
return (
|
|
381
|
+
<div
|
|
382
|
+
ref={viewportRef}
|
|
383
|
+
data-slot="gantt-viewport"
|
|
384
|
+
className={cn("relative min-h-0 flex-1 overflow-auto rounded-xl border bg-background text-sm", className)}
|
|
385
|
+
{...props}
|
|
386
|
+
/>
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Two-tier sticky header; the tiers depend on the scale. */
|
|
391
|
+
function GanttHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
392
|
+
const { start, end, days, dayWidth, scale, sidebarWidth } = useGantt()
|
|
393
|
+
const width = days * dayWidth
|
|
394
|
+
const tiers = React.useMemo(() => {
|
|
395
|
+
const clampX = (date: Date) => clamp(differenceInCalendarDays(date, start), 0, days)
|
|
396
|
+
const span = (from: Date, to: Date) => ({
|
|
397
|
+
left: clampX(from) * dayWidth,
|
|
398
|
+
width: (clampX(addDays(to, 1)) - clampX(from)) * dayWidth,
|
|
399
|
+
})
|
|
400
|
+
const months = eachMonthOfInterval({ start, end }).map((m) => ({
|
|
401
|
+
key: m.toISOString(),
|
|
402
|
+
label: format(m, scale === "month" ? "MMMM yyyy" : "MMM yyyy"),
|
|
403
|
+
...span(startOfMonth(m), endOfMonth(m)),
|
|
404
|
+
}))
|
|
405
|
+
if (scale === "day") {
|
|
406
|
+
const dayCells = eachDayOfInterval({ start, end }).map((d) => ({
|
|
407
|
+
key: d.toISOString(),
|
|
408
|
+
label: dayWidth >= 40 ? format(d, "EEEEE d") : format(d, "d"),
|
|
409
|
+
weekend: isWeekend(d),
|
|
410
|
+
today: isSameDay(d, new Date()),
|
|
411
|
+
...span(d, d),
|
|
412
|
+
}))
|
|
413
|
+
return { top: months, bottom: dayCells }
|
|
414
|
+
}
|
|
415
|
+
if (scale === "week") {
|
|
416
|
+
const weeks = eachWeekOfInterval({ start, end }, { weekStartsOn: 1 }).map((w) => ({
|
|
417
|
+
key: w.toISOString(),
|
|
418
|
+
label: format(w, "d MMM"),
|
|
419
|
+
weekend: false,
|
|
420
|
+
today: false,
|
|
421
|
+
...span(startOfWeek(w, { weekStartsOn: 1 }), endOfWeek(w, { weekStartsOn: 1 })),
|
|
422
|
+
}))
|
|
423
|
+
return { top: months, bottom: weeks }
|
|
424
|
+
}
|
|
425
|
+
const years = Array.from(new Set(months.map((m) => m.label.slice(-4)))).map((y) => {
|
|
426
|
+
const first = new Date(Number(y), 0, 1)
|
|
427
|
+
const last = new Date(Number(y), 11, 31)
|
|
428
|
+
return { key: y, label: y, ...span(first, last) }
|
|
429
|
+
})
|
|
430
|
+
return {
|
|
431
|
+
top: years,
|
|
432
|
+
bottom: months.map((m) => ({ ...m, label: format(new Date(m.key), "MMM"), weekend: false, today: false })),
|
|
433
|
+
}
|
|
434
|
+
}, [start, end, days, dayWidth, scale])
|
|
435
|
+
|
|
436
|
+
return (
|
|
437
|
+
<div
|
|
438
|
+
data-slot="gantt-header"
|
|
439
|
+
className={cn("sticky top-0 z-30 flex bg-muted text-xs text-muted-foreground", className)}
|
|
440
|
+
style={{ width: sidebarWidth + width }}
|
|
441
|
+
{...props}
|
|
442
|
+
>
|
|
443
|
+
<GanttHeaderCorner />
|
|
444
|
+
<div className="relative shrink-0" style={{ width }}>
|
|
445
|
+
<div className="flex h-7 border-b">
|
|
446
|
+
{tiers.top.map((cell) => (
|
|
447
|
+
<div
|
|
448
|
+
key={cell.key}
|
|
449
|
+
className="absolute inset-y-0 flex items-center truncate border-e px-2 font-medium"
|
|
450
|
+
style={{ left: cell.left, width: cell.width }}
|
|
451
|
+
>
|
|
452
|
+
{cell.label}
|
|
453
|
+
</div>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
<div className="flex h-7 border-b">
|
|
457
|
+
{tiers.bottom.map((cell) => (
|
|
458
|
+
<div
|
|
459
|
+
key={cell.key}
|
|
460
|
+
data-weekend={cell.weekend ? "" : undefined}
|
|
461
|
+
data-today={cell.today ? "" : undefined}
|
|
462
|
+
className="absolute flex h-7 items-center justify-center truncate border-e data-today:font-semibold data-today:text-foreground data-weekend:bg-foreground/3"
|
|
463
|
+
style={{ left: cell.left, width: cell.width, top: 28 }}
|
|
464
|
+
>
|
|
465
|
+
{cell.width >= 18 ? cell.label : ""}
|
|
466
|
+
</div>
|
|
467
|
+
))}
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The sticky cell above the sidebar; put a title or a search box in it. */
|
|
475
|
+
function GanttHeaderCorner({ className, ...props }: React.ComponentProps<"div">) {
|
|
476
|
+
return (
|
|
477
|
+
<div
|
|
478
|
+
data-slot="gantt-header-corner"
|
|
479
|
+
className={cn(
|
|
480
|
+
"sticky left-0 z-40 flex h-14 shrink-0 items-end border-e border-b bg-muted px-3 pb-2 font-medium",
|
|
481
|
+
className
|
|
482
|
+
)}
|
|
483
|
+
style={{ width: "var(--gantt-sidebar)" }}
|
|
484
|
+
{...props}
|
|
485
|
+
/>
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function GanttBody({ className, style, ...props }: React.ComponentProps<"div">) {
|
|
490
|
+
const { days, dayWidth, sidebarWidth } = useGantt()
|
|
491
|
+
return (
|
|
492
|
+
<div
|
|
493
|
+
data-slot="gantt-body"
|
|
494
|
+
className={cn("relative", className)}
|
|
495
|
+
style={{ width: sidebarWidth + days * dayWidth, ...style }}
|
|
496
|
+
{...props}
|
|
497
|
+
/>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Vertical day/week lines and weekend shading behind the bars. */
|
|
502
|
+
function GanttGridLines({ className, ...props }: React.ComponentProps<"div">) {
|
|
503
|
+
const { start, end, dayWidth, scale, sidebarWidth } = useGantt()
|
|
504
|
+
const cells = React.useMemo(() => {
|
|
505
|
+
if (scale === "month")
|
|
506
|
+
return eachMonthOfInterval({ start, end }).map((d) => ({ date: startOfMonth(d), weekend: false }))
|
|
507
|
+
if (scale === "week")
|
|
508
|
+
return eachWeekOfInterval({ start, end }, { weekStartsOn: 1 }).map((d) => ({ date: d, weekend: false }))
|
|
509
|
+
return eachDayOfInterval({ start, end }).map((d) => ({ date: d, weekend: isWeekend(d) }))
|
|
510
|
+
}, [start, end, scale])
|
|
511
|
+
return (
|
|
512
|
+
<div
|
|
513
|
+
data-slot="gantt-grid-lines"
|
|
514
|
+
aria-hidden
|
|
515
|
+
className={cn("pointer-events-none absolute inset-y-0", className)}
|
|
516
|
+
style={{ left: sidebarWidth, right: 0 }}
|
|
517
|
+
{...props}
|
|
518
|
+
>
|
|
519
|
+
{cells.map((cell) => (
|
|
520
|
+
<div
|
|
521
|
+
key={cell.date.toISOString()}
|
|
522
|
+
data-weekend={cell.weekend ? "" : undefined}
|
|
523
|
+
className="absolute inset-y-0 border-e border-border/60 data-weekend:bg-foreground/2.5"
|
|
524
|
+
style={{
|
|
525
|
+
left: differenceInCalendarDays(cell.date, start) * dayWidth,
|
|
526
|
+
width: scale === "day" ? dayWidth : undefined,
|
|
527
|
+
}}
|
|
528
|
+
/>
|
|
529
|
+
))}
|
|
530
|
+
</div>
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/* ---------------------------------------------------------------------------
|
|
535
|
+
* Rows
|
|
536
|
+
* ------------------------------------------------------------------------- */
|
|
537
|
+
|
|
538
|
+
/** Groups the rows; keep bars inside so dependency lines can find them. */
|
|
539
|
+
function GanttRows({ className, ...props }: React.ComponentProps<"div">) {
|
|
540
|
+
return <div data-slot="gantt-rows" className={cn("relative", className)} {...props} />
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function GanttRow({ value, className, children, ...props }: React.ComponentProps<"div"> & { value: string }) {
|
|
544
|
+
const { rowHeight } = useGantt()
|
|
545
|
+
const ctx = React.useMemo(() => ({ value }), [value])
|
|
546
|
+
return (
|
|
547
|
+
<RowContext.Provider value={ctx}>
|
|
548
|
+
<div
|
|
549
|
+
data-slot="gantt-row"
|
|
550
|
+
data-value={value}
|
|
551
|
+
className={cn("group/row flex border-b hover:bg-muted/30", className)}
|
|
552
|
+
style={{ height: rowHeight }}
|
|
553
|
+
{...props}
|
|
554
|
+
>
|
|
555
|
+
{children}
|
|
556
|
+
</div>
|
|
557
|
+
</RowContext.Provider>
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Sticky sidebar cell for the row. */
|
|
562
|
+
function GanttRowLabel({ className, ...props }: React.ComponentProps<"div">) {
|
|
563
|
+
return (
|
|
564
|
+
<div
|
|
565
|
+
data-slot="gantt-row-label"
|
|
566
|
+
className={cn(
|
|
567
|
+
"sticky left-0 z-20 flex shrink-0 items-center gap-2 border-e bg-background px-3 text-sm transition-colors",
|
|
568
|
+
"group-hover/row:bg-[color-mix(in_oklab,var(--color-muted)_30%,var(--color-background))]",
|
|
569
|
+
className
|
|
570
|
+
)}
|
|
571
|
+
style={{ width: "var(--gantt-sidebar)" }}
|
|
572
|
+
{...props}
|
|
573
|
+
/>
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** The timeline part of a row; bars are positioned inside it. */
|
|
578
|
+
function GanttRowTrack({ className, ...props }: React.ComponentProps<"div">) {
|
|
579
|
+
const { days, dayWidth } = useGantt()
|
|
580
|
+
return (
|
|
581
|
+
<div
|
|
582
|
+
data-slot="gantt-row-track"
|
|
583
|
+
className={cn("relative shrink-0", className)}
|
|
584
|
+
style={{ width: days * dayWidth }}
|
|
585
|
+
{...props}
|
|
586
|
+
/>
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/* ---------------------------------------------------------------------------
|
|
591
|
+
* Bars
|
|
592
|
+
* ------------------------------------------------------------------------- */
|
|
593
|
+
|
|
594
|
+
const BarContext = React.createContext<BarRegistration | null>(null)
|
|
595
|
+
|
|
596
|
+
function GanttBar({
|
|
597
|
+
value,
|
|
598
|
+
start,
|
|
599
|
+
end,
|
|
600
|
+
progress,
|
|
601
|
+
className,
|
|
602
|
+
style,
|
|
603
|
+
children,
|
|
604
|
+
onPointerDown,
|
|
605
|
+
onKeyDown,
|
|
606
|
+
onBlur,
|
|
607
|
+
...props
|
|
608
|
+
}: React.ComponentProps<"div"> & { value: string; start: Date; end: Date; progress?: number }) {
|
|
609
|
+
const gantt = useGantt()
|
|
610
|
+
useGanttRow()
|
|
611
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
612
|
+
const live = gantt.interaction?.id === value ? gantt.interaction : null
|
|
613
|
+
// Key on timestamps: callers often pass fresh Date objects every render.
|
|
614
|
+
const startMs = (live ? live.start : startOfDay(start)).getTime()
|
|
615
|
+
const endMs = (live ? live.end : startOfDay(end)).getTime()
|
|
616
|
+
const registration = React.useMemo<BarRegistration>(
|
|
617
|
+
() => ({ id: value, el: null, start: new Date(startMs), end: new Date(endMs) }),
|
|
618
|
+
[value, startMs, endMs]
|
|
619
|
+
)
|
|
620
|
+
const shownStart = registration.start
|
|
621
|
+
const shownEnd = registration.end
|
|
622
|
+
const { registerBar } = gantt
|
|
623
|
+
React.useLayoutEffect(() => registerBar({ ...registration, el: ref.current }), [registerBar, registration])
|
|
624
|
+
const left = gantt.xOf(shownStart)
|
|
625
|
+
const width = Math.max(gantt.dayWidth, (differenceInCalendarDays(shownEnd, shownStart) + 1) * gantt.dayWidth)
|
|
626
|
+
const isGrabbed = gantt.grabbed === value
|
|
627
|
+
|
|
628
|
+
return (
|
|
629
|
+
<BarContext.Provider value={registration}>
|
|
630
|
+
<div
|
|
631
|
+
ref={ref}
|
|
632
|
+
data-slot="gantt-bar"
|
|
633
|
+
data-value={value}
|
|
634
|
+
data-dragging={live ? live.type : undefined}
|
|
635
|
+
data-grabbed={isGrabbed ? "" : undefined}
|
|
636
|
+
tabIndex={gantt.editable ? 0 : undefined}
|
|
637
|
+
aria-pressed={gantt.editable ? isGrabbed : undefined}
|
|
638
|
+
aria-label={`${format(shownStart, "MMM d")} to ${format(shownEnd, "MMM d")}`}
|
|
639
|
+
className={cn(
|
|
640
|
+
"group/bar absolute top-1/2 z-10 flex h-7 -translate-y-1/2 items-center overflow-hidden rounded-md border border-primary/30 bg-primary/15 text-xs text-foreground outline-none select-none",
|
|
641
|
+
gantt.editable && "cursor-grab active:cursor-grabbing",
|
|
642
|
+
"focus-visible:ring-2 focus-visible:ring-ring/50 data-dragging:z-20 data-dragging:shadow-md data-grabbed:ring-2 data-grabbed:ring-primary",
|
|
643
|
+
className
|
|
644
|
+
)}
|
|
645
|
+
style={{ left, width, ...style }}
|
|
646
|
+
onPointerDown={(event) => {
|
|
647
|
+
onPointerDown?.(event)
|
|
648
|
+
if (event.defaultPrevented || event.button !== 0) return
|
|
649
|
+
if ((event.target as HTMLElement).closest("[data-slot=gantt-bar-resize-handle], button, a, input")) return
|
|
650
|
+
gantt.startInteraction(registration, "move", event)
|
|
651
|
+
}}
|
|
652
|
+
onKeyDown={(event) => {
|
|
653
|
+
onKeyDown?.(event)
|
|
654
|
+
if (event.defaultPrevented || !gantt.editable || event.target !== event.currentTarget) return
|
|
655
|
+
if (event.key === " " || event.key === "Enter") {
|
|
656
|
+
event.preventDefault()
|
|
657
|
+
gantt.grab(isGrabbed ? null : value)
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
if (!isGrabbed) return
|
|
661
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
662
|
+
event.preventDefault()
|
|
663
|
+
const delta = event.key === "ArrowLeft" ? -1 : 1
|
|
664
|
+
gantt.nudge(registration, delta, event.shiftKey ? "end" : event.altKey ? "start" : "both")
|
|
665
|
+
} else if (event.key === "Escape") {
|
|
666
|
+
event.preventDefault()
|
|
667
|
+
gantt.cancelGrab()
|
|
668
|
+
}
|
|
669
|
+
}}
|
|
670
|
+
onBlur={(event) => {
|
|
671
|
+
onBlur?.(event)
|
|
672
|
+
if (isGrabbed && !event.currentTarget.contains(event.relatedTarget as Node | null)) gantt.grab(null)
|
|
673
|
+
}}
|
|
674
|
+
{...props}
|
|
675
|
+
>
|
|
676
|
+
{progress !== undefined && (
|
|
677
|
+
<div
|
|
678
|
+
data-slot="gantt-bar-progress"
|
|
679
|
+
aria-hidden
|
|
680
|
+
className="absolute inset-y-0 left-0 bg-primary/30"
|
|
681
|
+
style={{ width: `${clamp(progress, 0, 1) * 100}%` }}
|
|
682
|
+
/>
|
|
683
|
+
)}
|
|
684
|
+
{children}
|
|
685
|
+
</div>
|
|
686
|
+
</BarContext.Provider>
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function GanttBarLabel({ className, ...props }: React.ComponentProps<"span">) {
|
|
691
|
+
return (
|
|
692
|
+
<span data-slot="gantt-bar-label" className={cn("relative z-10 truncate px-2 font-medium", className)} {...props} />
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function GanttBarResizeHandle({
|
|
697
|
+
side,
|
|
698
|
+
className,
|
|
699
|
+
onPointerDown,
|
|
700
|
+
...props
|
|
701
|
+
}: React.ComponentProps<"div"> & { side: "start" | "end" }) {
|
|
702
|
+
const gantt = useGantt()
|
|
703
|
+
const bar = React.useContext(BarContext)
|
|
704
|
+
if (!gantt.editable || !bar) return null
|
|
705
|
+
return (
|
|
706
|
+
<div
|
|
707
|
+
data-slot="gantt-bar-resize-handle"
|
|
708
|
+
data-side={side}
|
|
709
|
+
role="separator"
|
|
710
|
+
aria-orientation="vertical"
|
|
711
|
+
className={cn(
|
|
712
|
+
"absolute inset-y-0 z-10 w-2 cursor-ew-resize touch-none opacity-0 transition-opacity group-hover/bar:opacity-100 group-focus-visible/bar:opacity-100",
|
|
713
|
+
side === "start" ? "left-0 rounded-l-md" : "right-0 rounded-r-md",
|
|
714
|
+
"after:absolute after:inset-y-1.5 after:w-0.5 after:rounded-full after:bg-primary/60",
|
|
715
|
+
side === "start" ? "after:left-0.5" : "after:right-0.5",
|
|
716
|
+
className
|
|
717
|
+
)}
|
|
718
|
+
onPointerDown={(event) => {
|
|
719
|
+
onPointerDown?.(event)
|
|
720
|
+
if (event.defaultPrevented || event.button !== 0) return
|
|
721
|
+
gantt.startInteraction(bar, side === "start" ? "resize-start" : "resize-end", event)
|
|
722
|
+
}}
|
|
723
|
+
{...props}
|
|
724
|
+
/>
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** A single-day marker rendered as a diamond. */
|
|
729
|
+
function GanttMilestone({
|
|
730
|
+
value,
|
|
731
|
+
date,
|
|
732
|
+
className,
|
|
733
|
+
style,
|
|
734
|
+
children,
|
|
735
|
+
...props
|
|
736
|
+
}: React.ComponentProps<"div"> & { value: string; date: Date }) {
|
|
737
|
+
const gantt = useGantt()
|
|
738
|
+
useGanttRow()
|
|
739
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
740
|
+
const dayMs = startOfDay(date).getTime()
|
|
741
|
+
const registration = React.useMemo<BarRegistration>(
|
|
742
|
+
() => ({ id: value, el: null, start: new Date(dayMs), end: new Date(dayMs), milestone: true }),
|
|
743
|
+
[value, dayMs]
|
|
744
|
+
)
|
|
745
|
+
const day = registration.start
|
|
746
|
+
const { registerBar } = gantt
|
|
747
|
+
React.useLayoutEffect(() => registerBar({ ...registration, el: ref.current }), [registerBar, registration])
|
|
748
|
+
return (
|
|
749
|
+
<div
|
|
750
|
+
ref={ref}
|
|
751
|
+
data-slot="gantt-milestone"
|
|
752
|
+
data-value={value}
|
|
753
|
+
aria-label={format(day, "MMM d")}
|
|
754
|
+
className={cn("absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-2", className)}
|
|
755
|
+
style={{ left: gantt.xOf(day) + gantt.dayWidth / 2 - 8, ...style }}
|
|
756
|
+
{...props}
|
|
757
|
+
>
|
|
758
|
+
<span aria-hidden className="block size-4 rotate-45 rounded-sm bg-foreground" />
|
|
759
|
+
{children && <span className="text-xs font-medium whitespace-nowrap">{children}</span>}
|
|
760
|
+
</div>
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/* ---------------------------------------------------------------------------
|
|
765
|
+
* Dependencies, today, empty
|
|
766
|
+
* ------------------------------------------------------------------------- */
|
|
767
|
+
|
|
768
|
+
/** Arrows from the end of `from` to the start of `to`, measured from the registered bar elements. */
|
|
769
|
+
function GanttDependencies({
|
|
770
|
+
links,
|
|
771
|
+
className,
|
|
772
|
+
...props
|
|
773
|
+
}: React.ComponentProps<"svg"> & { links: { from: string; to: string }[] }) {
|
|
774
|
+
const { barStore, dayWidth, xOf, sidebarWidth, rowHeight, days } = useGantt()
|
|
775
|
+
React.useSyncExternalStore(barStore.subscribe, barStore.getVersion, barStore.getVersion)
|
|
776
|
+
const svgRef = React.useRef<SVGSVGElement>(null)
|
|
777
|
+
const id = React.useId()
|
|
778
|
+
// Row centres come from the DOM so collapsed groups and custom row order need no bookkeeping.
|
|
779
|
+
const origin = svgRef.current?.getBoundingClientRect()
|
|
780
|
+
const centreY = (el: HTMLElement | null) => {
|
|
781
|
+
if (!el || !origin) return null
|
|
782
|
+
const r = el.getBoundingClientRect()
|
|
783
|
+
return r.top + r.height / 2 - origin.top
|
|
784
|
+
}
|
|
785
|
+
const paths = links
|
|
786
|
+
.map((link) => {
|
|
787
|
+
const from = barStore.bars.get(link.from)
|
|
788
|
+
const to = barStore.bars.get(link.to)
|
|
789
|
+
if (!from || !to) return null
|
|
790
|
+
const y1 = centreY(from.el)
|
|
791
|
+
const y2 = centreY(to.el)
|
|
792
|
+
if (y1 === null || y2 === null) return null
|
|
793
|
+
const x1 = xOf(from.end) + (from.milestone ? dayWidth / 2 + 8 : dayWidth)
|
|
794
|
+
const x2 = xOf(to.start) + (to.milestone ? dayWidth / 2 - 8 : 0)
|
|
795
|
+
const gap = 10
|
|
796
|
+
const d =
|
|
797
|
+
x2 - gap > x1
|
|
798
|
+
? `M ${x1} ${y1} H ${x1 + gap} V ${y2} H ${x2}`
|
|
799
|
+
: `M ${x1} ${y1} H ${x1 + gap} V ${y1 + rowHeight / 2 - 4} H ${x2 - gap} V ${y2} H ${x2}`
|
|
800
|
+
return { key: `${link.from}->${link.to}`, d }
|
|
801
|
+
})
|
|
802
|
+
.filter((p): p is { key: string; d: string } => !!p)
|
|
803
|
+
return (
|
|
804
|
+
<svg
|
|
805
|
+
ref={svgRef}
|
|
806
|
+
data-slot="gantt-dependencies"
|
|
807
|
+
aria-hidden
|
|
808
|
+
className={cn("pointer-events-none absolute top-0 z-5 overflow-hidden text-muted-foreground", className)}
|
|
809
|
+
style={{ left: sidebarWidth, width: days * dayWidth, height: "100%" }}
|
|
810
|
+
{...props}
|
|
811
|
+
>
|
|
812
|
+
<defs>
|
|
813
|
+
<marker id={`${id}-arrow`} viewBox="0 0 6 6" refX="5" refY="3" markerWidth="6" markerHeight="6" orient="auto">
|
|
814
|
+
<path d="M 0 0 L 6 3 L 0 6 z" fill="currentColor" />
|
|
815
|
+
</marker>
|
|
816
|
+
</defs>
|
|
817
|
+
{paths.map((p) => (
|
|
818
|
+
<path key={p.key} d={p.d} fill="none" stroke="currentColor" strokeWidth={1.5} markerEnd={`url(#${id}-arrow)`} />
|
|
819
|
+
))}
|
|
820
|
+
</svg>
|
|
821
|
+
)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function GanttToday({ className, date = new Date(), ...props }: React.ComponentProps<"div"> & { date?: Date }) {
|
|
825
|
+
const { xOf, dayWidth, sidebarWidth, start, end } = useGantt()
|
|
826
|
+
const day = startOfDay(date)
|
|
827
|
+
if (day < start || day > end) return null
|
|
828
|
+
return (
|
|
829
|
+
<div
|
|
830
|
+
data-slot="gantt-today"
|
|
831
|
+
aria-hidden
|
|
832
|
+
className={cn("pointer-events-none absolute inset-y-0 w-px bg-destructive", className)}
|
|
833
|
+
style={{ left: sidebarWidth + xOf(day) + dayWidth / 2 }}
|
|
834
|
+
{...props}
|
|
835
|
+
>
|
|
836
|
+
<span className="absolute top-0 left-1 rounded-b bg-destructive px-1 text-[10px]/4 text-white">Today</span>
|
|
837
|
+
</div>
|
|
838
|
+
)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function GanttEmpty({ className, ...props }: React.ComponentProps<"div">) {
|
|
842
|
+
return (
|
|
843
|
+
<div
|
|
844
|
+
data-slot="gantt-empty"
|
|
845
|
+
className={cn("flex h-32 items-center justify-center text-sm text-muted-foreground", className)}
|
|
846
|
+
{...props}
|
|
847
|
+
/>
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/* ---------------------------------------------------------------------------
|
|
852
|
+
* Controls
|
|
853
|
+
* ------------------------------------------------------------------------- */
|
|
854
|
+
|
|
855
|
+
function GanttControls({ className, ...props }: React.ComponentProps<"div">) {
|
|
856
|
+
return <div data-slot="gantt-controls" className={cn("flex items-center gap-1", className)} {...props} />
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function GanttZoomInTrigger({ asChild, children, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
|
860
|
+
const { zoomIn } = useGantt()
|
|
861
|
+
return (
|
|
862
|
+
<Button
|
|
863
|
+
data-slot="gantt-zoom-in-trigger"
|
|
864
|
+
variant="outline"
|
|
865
|
+
size="icon-sm"
|
|
866
|
+
aria-label="Zoom in"
|
|
867
|
+
asChild={asChild}
|
|
868
|
+
onClick={(event) => {
|
|
869
|
+
onClick?.(event)
|
|
870
|
+
if (!event.defaultPrevented) zoomIn()
|
|
871
|
+
}}
|
|
872
|
+
{...props}
|
|
873
|
+
>
|
|
874
|
+
{asChild ? children : (children ?? <PlusIcon />)}
|
|
875
|
+
</Button>
|
|
876
|
+
)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function GanttZoomOutTrigger({ asChild, children, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
|
880
|
+
const { zoomOut } = useGantt()
|
|
881
|
+
return (
|
|
882
|
+
<Button
|
|
883
|
+
data-slot="gantt-zoom-out-trigger"
|
|
884
|
+
variant="outline"
|
|
885
|
+
size="icon-sm"
|
|
886
|
+
aria-label="Zoom out"
|
|
887
|
+
asChild={asChild}
|
|
888
|
+
onClick={(event) => {
|
|
889
|
+
onClick?.(event)
|
|
890
|
+
if (!event.defaultPrevented) zoomOut()
|
|
891
|
+
}}
|
|
892
|
+
{...props}
|
|
893
|
+
>
|
|
894
|
+
{asChild ? children : (children ?? <MinusIcon />)}
|
|
895
|
+
</Button>
|
|
896
|
+
)
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function GanttTodayTrigger({ asChild, children, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
|
900
|
+
const { scrollToDate } = useGantt()
|
|
901
|
+
return (
|
|
902
|
+
<Button
|
|
903
|
+
data-slot="gantt-today-trigger"
|
|
904
|
+
variant="outline"
|
|
905
|
+
size="sm"
|
|
906
|
+
asChild={asChild}
|
|
907
|
+
onClick={(event) => {
|
|
908
|
+
onClick?.(event)
|
|
909
|
+
if (!event.defaultPrevented) scrollToDate(new Date())
|
|
910
|
+
}}
|
|
911
|
+
{...props}
|
|
912
|
+
>
|
|
913
|
+
{asChild
|
|
914
|
+
? children
|
|
915
|
+
: (children ?? (
|
|
916
|
+
<>
|
|
917
|
+
<CalendarIcon /> Today
|
|
918
|
+
</>
|
|
919
|
+
))}
|
|
920
|
+
</Button>
|
|
921
|
+
)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export {
|
|
925
|
+
Gantt,
|
|
926
|
+
GanttViewport,
|
|
927
|
+
GanttHeader,
|
|
928
|
+
GanttHeaderCorner,
|
|
929
|
+
GanttBody,
|
|
930
|
+
GanttGridLines,
|
|
931
|
+
GanttRows,
|
|
932
|
+
GanttRow,
|
|
933
|
+
GanttRowLabel,
|
|
934
|
+
GanttRowTrack,
|
|
935
|
+
GanttBar,
|
|
936
|
+
GanttBarLabel,
|
|
937
|
+
GanttBarResizeHandle,
|
|
938
|
+
GanttMilestone,
|
|
939
|
+
GanttDependencies,
|
|
940
|
+
GanttToday,
|
|
941
|
+
GanttEmpty,
|
|
942
|
+
GanttControls,
|
|
943
|
+
GanttZoomInTrigger,
|
|
944
|
+
GanttZoomOutTrigger,
|
|
945
|
+
GanttTodayTrigger,
|
|
946
|
+
useGantt,
|
|
947
|
+
useGanttRow,
|
|
948
|
+
type GanttScale,
|
|
949
|
+
type GanttBarChange,
|
|
950
|
+
}
|