@vvedantb/ui 0.17.1 → 0.18.1
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 +251 -3
- package/package.json +13 -5
- package/shell/src/SidebarResize.tsx +1 -4
- package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
- package/src/ai-elements/activity-question-utils.ts +26 -0
- package/src/ai-elements/activity-question.tsx +159 -0
- package/src/ai-elements/activity-shared.tsx +459 -0
- package/src/ai-elements/activity-step-detail.tsx +115 -0
- package/src/ai-elements/activity-step-label.ts +981 -0
- package/src/ai-elements/activity-tasks-utils.ts +199 -0
- package/src/ai-elements/activity-tasks.tsx +770 -0
- package/src/ai-elements/artifact.tsx +162 -52
- package/src/ai-elements/attachments.tsx +62 -32
- package/src/ai-elements/code-block.tsx +76 -22
- package/src/ai-elements/context.tsx +370 -0
- package/src/ai-elements/conversation.tsx +186 -62
- package/src/ai-elements/file-chip.tsx +53 -0
- package/src/ai-elements/message.tsx +161 -50
- package/src/ai-elements/model-picker-content.tsx +505 -0
- package/src/ai-elements/model-picker-types.ts +56 -0
- package/src/ai-elements/model-picker.tsx +301 -0
- package/src/ai-elements/model-selector.tsx +102 -21
- package/src/ai-elements/plan.tsx +130 -0
- package/src/ai-elements/prompt-input-settings.tsx +144 -0
- package/src/ai-elements/prompt-input-speech.tsx +189 -0
- package/src/ai-elements/prompt-input-traits.tsx +281 -0
- package/src/ai-elements/prompt-input.tsx +320 -134
- package/src/ai-elements/provider-icon.tsx +133 -0
- package/src/ai-elements/queue.tsx +293 -9
- package/src/ai-elements/shimmer.tsx +72 -1
- package/src/ai-elements/simple-model-ladder.tsx +108 -0
- package/src/ai-elements/speech-input.tsx +5 -3
- package/src/ai-elements/test-results.tsx +510 -0
- package/src/ai-elements/trace.tsx +243 -0
- package/src/ai-elements/use-streaming-disclosure.ts +50 -0
- package/src/ai-elements/web-preview.tsx +349 -0
- package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
- package/src/index.ts +66 -12
- package/src/kibo/avatar-stack.tsx +40 -0
- package/src/kibo/contribution-graph.tsx +539 -0
- package/src/kibo/cursor.tsx +72 -0
- package/src/kibo/gantt-features.tsx +439 -0
- package/src/kibo/gantt-header.tsx +181 -0
- package/src/kibo/gantt-provider.tsx +657 -0
- package/src/kibo/gantt-sidebar.tsx +157 -0
- package/src/kibo/gantt-timeline.tsx +154 -0
- package/src/kibo/index.ts +151 -0
- package/src/kibo/kanban.tsx +338 -0
- package/src/kibo/kanbanDropTarget.ts +25 -0
- package/src/kibo/list.tsx +162 -0
- package/src/kibo/theme-switcher.tsx +109 -0
- package/src/kibo/video-player.tsx +237 -0
- package/src/markdown/Markdown.tsx +112 -0
- package/src/markdown/MarkdownBlockquote.tsx +45 -0
- package/src/markdown/MarkdownCodeBlock.tsx +197 -0
- package/src/markdown/MarkdownInlineCode.tsx +40 -0
- package/src/markdown/MarkdownLink.tsx +92 -0
- package/src/markdown/codeFence.ts +57 -0
- package/src/markdown/codeHighlight.ts +138 -0
- package/src/markdown/incrementalBlocks.ts +182 -0
- package/src/markdown/index.ts +3 -0
- package/src/markdown/markdownClipboard.ts +178 -0
- package/src/markdown/mermaid.ts +155 -0
- package/src/markdown/rehypeGithubAlerts.ts +80 -0
- package/src/motion/presets.ts +24 -0
- package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
- package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
- package/src/ui/_menu-parts.tsx +29 -0
- package/src/ui/accordion.alt.tsx +46 -53
- package/src/ui/accordion.tsx +59 -10
- package/src/ui/avatar.tsx +56 -0
- package/src/ui/badge.tsx +45 -21
- package/src/ui/border-beam.tsx +97 -0
- package/src/ui/button-group.tsx +50 -21
- package/src/ui/button.tsx +67 -45
- package/src/ui/calendar.tsx +48 -5
- package/src/ui/card.tsx +77 -8
- package/src/ui/carousel.tsx +60 -0
- package/src/ui/centered-spinner.tsx +30 -0
- package/src/ui/checkbox.tsx +34 -16
- package/src/ui/clear-input.alt.tsx +33 -75
- package/src/ui/clear-input.tsx +140 -15
- package/src/ui/collapsible.tsx +40 -2
- package/src/ui/command.tsx +143 -70
- package/src/ui/context-menu.tsx +159 -93
- package/src/ui/crossfade-icon.tsx +123 -0
- package/src/ui/dialog.tsx +154 -70
- package/src/ui/dropdown-menu.tsx +177 -117
- package/src/ui/hover-card.tsx +59 -17
- package/src/ui/input-group.alt.tsx +83 -83
- package/src/ui/input-group.tsx +129 -58
- package/src/ui/input.tsx +13 -4
- package/src/ui/label.tsx +11 -8
- package/src/ui/list-row.tsx +232 -0
- package/src/ui/loading-state.tsx +215 -0
- package/src/ui/popover.tsx +56 -23
- package/src/ui/progress.tsx +28 -16
- package/src/ui/refresh-spin-icon.tsx +41 -0
- package/src/ui/search-input.tsx +66 -15
- package/src/ui/select.tsx +171 -100
- package/src/ui/separator.tsx +23 -14
- package/src/ui/slider.tsx +21 -7
- package/src/ui/sonner.tsx +68 -30
- package/src/ui/spinner.tsx +229 -11
- package/src/ui/status-dot.tsx +84 -0
- package/src/ui/surface.tsx +49 -0
- package/src/ui/switch.tsx +29 -15
- package/src/ui/table.tsx +38 -16
- package/src/ui/tabs.tsx +123 -35
- package/src/ui/tabsSliding.ts +33 -1
- package/src/ui/textarea.tsx +13 -4
- package/src/ui/tooltip.tsx +50 -14
- package/src/ui/ui-config.tsx +149 -0
- package/src/utils/cat-color.ts +123 -0
- package/src/utils/dayjs.ts +6 -0
- package/src/utils/gesture.ts +59 -0
- package/src/utils/runtimeVisibility.ts +167 -0
- package/src/utils/sharedClock.ts +96 -0
- package/src/utils/surface-radius.ts +16 -0
- package/src/utils/useDragSensors.ts +58 -0
- package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
- package/tokens/examples/tokens-moved/README.md +20 -0
- package/tokens/examples/tokens-moved/index.html +12 -0
- package/tokens/examples/tokens-moved/main.tsx +6 -0
- package/tokens/examples/tokens-moved/package.json +14 -0
- package/tokens/examples/tokens-moved/styles.css +12 -0
- package/tokens/examples/tokens-moved/tsconfig.json +14 -0
- package/tokens/examples/tokens-moved/vite.config.ts +11 -0
- package/tokens/src/css/border-beam.css +116 -0
- package/tokens/src/css/markdown.css +318 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import dayjs from "../utils/dayjs";
|
|
4
|
+
import type { CSSProperties, FC, ReactNode, RefObject } from "react";
|
|
5
|
+
import {
|
|
6
|
+
createContext,
|
|
7
|
+
useCallback,
|
|
8
|
+
useContext,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { useCn, useUiConfig } from "../ui/ui-config";
|
|
15
|
+
|
|
16
|
+
function throttle(fn: () => void, ms: number): () => void {
|
|
17
|
+
let last = 0;
|
|
18
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
19
|
+
return () => {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const remaining = ms - (now - last);
|
|
22
|
+
if (remaining <= 0) {
|
|
23
|
+
if (timer) clearTimeout(timer);
|
|
24
|
+
last = now;
|
|
25
|
+
fn();
|
|
26
|
+
} else if (!timer) {
|
|
27
|
+
timer = setTimeout(() => {
|
|
28
|
+
last = Date.now();
|
|
29
|
+
timer = null;
|
|
30
|
+
fn();
|
|
31
|
+
}, remaining);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GanttConfig {
|
|
37
|
+
/** Replaces the built-in classes of a part. */
|
|
38
|
+
classes?: {
|
|
39
|
+
/**
|
|
40
|
+
* `GanttProvider`'s scroll container: grid, scrolling with no visible
|
|
41
|
+
* scrollbar, radius and fill. The range name and `className` follow.
|
|
42
|
+
*/
|
|
43
|
+
root?: string;
|
|
44
|
+
/** `GanttToday`'s pill (the caller `className` merges after it). */
|
|
45
|
+
today?: string;
|
|
46
|
+
/** The date that slides open under "Today" on hover. */
|
|
47
|
+
todayDate?: string;
|
|
48
|
+
/** The resize grip inside `GanttFeatureDragHelper`. */
|
|
49
|
+
dragHandle?: string;
|
|
50
|
+
/** The date chip `GanttFeatureItem` pins to the edge when off screen. */
|
|
51
|
+
offscreenIndicator?: string;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Built-in classes of every gantt part, replaced by `GanttConfig.classes`. */
|
|
56
|
+
export const ganttDefaultClasses = {
|
|
57
|
+
root: "gantt relative isolate grid h-full w-full flex-none select-none overflow-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden rounded-surface bg-card",
|
|
58
|
+
today:
|
|
59
|
+
"group pointer-events-auto sticky top-0 z-1 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-primary px-2 py-0.5 text-[0.625rem] leading-[0.875rem] font-medium text-primary-foreground",
|
|
60
|
+
todayDate:
|
|
61
|
+
"max-h-0 overflow-hidden opacity-90 transition-[max-height] duration-200 group-hover:max-h-8",
|
|
62
|
+
dragHandle:
|
|
63
|
+
"-translate-y-1/2 absolute top-1/2 h-[80%] w-1 rounded-sm bg-muted-foreground opacity-0 transition-[opacity,translate] duration-150",
|
|
64
|
+
offscreenIndicator:
|
|
65
|
+
"absolute top-1/2 z-4 flex -translate-y-1/2 items-center gap-1 whitespace-nowrap rounded-control border border-border bg-background/95 px-2 py-0.5 text-[0.625rem] leading-[0.875rem] font-medium text-muted-foreground backdrop-blur-sm transition-colors hover:text-foreground",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Gantt part classes: the app's `UiConfig.Gantt.classes` over the defaults. */
|
|
69
|
+
export function useGanttClasses(): typeof ganttDefaultClasses {
|
|
70
|
+
return { ...ganttDefaultClasses, ...useUiConfig("Gantt")?.classes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type GanttStatus = {
|
|
74
|
+
id: string;
|
|
75
|
+
name: string;
|
|
76
|
+
color: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type GanttFeature = {
|
|
80
|
+
id: string;
|
|
81
|
+
name: string;
|
|
82
|
+
startAt: Date;
|
|
83
|
+
endAt: Date;
|
|
84
|
+
status: GanttStatus;
|
|
85
|
+
lane?: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type GanttMarkerProps = {
|
|
89
|
+
id: string;
|
|
90
|
+
date: Date;
|
|
91
|
+
label: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type GanttRange = "daily" | "monthly" | "quarterly";
|
|
95
|
+
|
|
96
|
+
export type GanttTimelineData = {
|
|
97
|
+
year: number;
|
|
98
|
+
quarters: {
|
|
99
|
+
months: {
|
|
100
|
+
days: number;
|
|
101
|
+
}[];
|
|
102
|
+
}[];
|
|
103
|
+
}[];
|
|
104
|
+
|
|
105
|
+
export type GanttContextProps = {
|
|
106
|
+
zoom: number;
|
|
107
|
+
range: GanttRange;
|
|
108
|
+
columnWidth: number;
|
|
109
|
+
sidebarWidth: number;
|
|
110
|
+
headerHeight: number;
|
|
111
|
+
rowHeight: number;
|
|
112
|
+
onAddItem: ((date: Date) => void) | undefined;
|
|
113
|
+
placeholderLength: number;
|
|
114
|
+
timelineData: GanttTimelineData;
|
|
115
|
+
ref: RefObject<HTMLDivElement | null> | null;
|
|
116
|
+
scrollToFeature?: (feature: GanttFeature) => void;
|
|
117
|
+
scrollToToday?: () => void;
|
|
118
|
+
dragging: boolean;
|
|
119
|
+
setDragging: (v: boolean) => void;
|
|
120
|
+
scrollX: number;
|
|
121
|
+
setScrollX: (v: number) => void;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** The issue-list column at desktop width. */
|
|
125
|
+
const GANTT_SIDEBAR_WIDTH_PX = 300;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The sidebar is a grid track, not a sized element, so its width has to be a
|
|
129
|
+
* number. Hardcoded at 300 it left roughly 90px of chart canvas on a 390px
|
|
130
|
+
* phone — the timeline was there, just not wide enough to read. Capped at 45% of
|
|
131
|
+
* the viewport, a phone keeps the majority of the width for the bars while
|
|
132
|
+
* desktop is unchanged.
|
|
133
|
+
*/
|
|
134
|
+
function ganttSidebarWidth(): number {
|
|
135
|
+
return Math.min(GANTT_SIDEBAR_WIDTH_PX, Math.round(window.innerWidth * 0.45));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const GanttContext = createContext<GanttContextProps>({
|
|
139
|
+
zoom: 100,
|
|
140
|
+
range: "monthly",
|
|
141
|
+
columnWidth: 50,
|
|
142
|
+
headerHeight: 60,
|
|
143
|
+
sidebarWidth: GANTT_SIDEBAR_WIDTH_PX,
|
|
144
|
+
rowHeight: 36,
|
|
145
|
+
onAddItem: undefined,
|
|
146
|
+
placeholderLength: 2,
|
|
147
|
+
timelineData: [],
|
|
148
|
+
ref: null,
|
|
149
|
+
scrollToFeature: undefined,
|
|
150
|
+
scrollToToday: undefined,
|
|
151
|
+
dragging: false,
|
|
152
|
+
setDragging: () => {},
|
|
153
|
+
scrollX: 0,
|
|
154
|
+
setScrollX: () => {},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
export const useGanttContext = () => useContext(GanttContext);
|
|
158
|
+
|
|
159
|
+
export const getDifferenceIn = (range: GanttRange) => {
|
|
160
|
+
if (range === "monthly" || range === "quarterly") {
|
|
161
|
+
return (a: Date, b: Date) => dayjs(a).diff(dayjs(b), "month");
|
|
162
|
+
}
|
|
163
|
+
return (a: Date, b: Date) => dayjs(a).diff(dayjs(b), "day");
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const getInnerDifferenceIn = (range: GanttRange) => {
|
|
167
|
+
if (range === "monthly" || range === "quarterly") {
|
|
168
|
+
return (a: Date, b: Date) => dayjs(a).diff(dayjs(b), "day");
|
|
169
|
+
}
|
|
170
|
+
return (a: Date, b: Date) => dayjs(a).diff(dayjs(b), "hour");
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const getStartOf = (range: GanttRange) => {
|
|
174
|
+
if (range === "monthly" || range === "quarterly") {
|
|
175
|
+
return (date: Date) => dayjs(date).startOf("month").toDate();
|
|
176
|
+
}
|
|
177
|
+
return (date: Date) => dayjs(date).startOf("day").toDate();
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const getEndOf = (range: GanttRange) => {
|
|
181
|
+
if (range === "monthly" || range === "quarterly") {
|
|
182
|
+
return (date: Date) => dayjs(date).endOf("month").toDate();
|
|
183
|
+
}
|
|
184
|
+
return (date: Date) => dayjs(date).endOf("day").toDate();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const getAddRange = (range: GanttRange) => {
|
|
188
|
+
if (range === "monthly" || range === "quarterly") {
|
|
189
|
+
return (date: Date, n: number) => dayjs(date).add(n, "month").toDate();
|
|
190
|
+
}
|
|
191
|
+
return (date: Date, n: number) => dayjs(date).add(n, "day").toDate();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const createInitialTimelineData = (today: Date) => {
|
|
195
|
+
const data: GanttTimelineData = [];
|
|
196
|
+
const currentYear = today.getFullYear();
|
|
197
|
+
|
|
198
|
+
for (let y = currentYear - 1; y <= currentYear + 1; y++) {
|
|
199
|
+
const yearObj: GanttTimelineData[number] = { year: y, quarters: [] };
|
|
200
|
+
for (let q = 0; q < 4; q++) {
|
|
201
|
+
const months = [];
|
|
202
|
+
for (let m = 0; m < 3; m++) {
|
|
203
|
+
const month = q * 3 + m;
|
|
204
|
+
months.push({ days: dayjs(new Date(y, month, 1)).daysInMonth() });
|
|
205
|
+
}
|
|
206
|
+
yearObj.quarters.push({ months });
|
|
207
|
+
}
|
|
208
|
+
data.push(yearObj);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return data;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const getOffset = (
|
|
215
|
+
date: Date,
|
|
216
|
+
timelineStartDate: Date,
|
|
217
|
+
context: GanttContextProps,
|
|
218
|
+
) => {
|
|
219
|
+
const parsedColumnWidth = (context.columnWidth * context.zoom) / 100;
|
|
220
|
+
const differenceIn = getDifferenceIn(context.range);
|
|
221
|
+
const startOf = getStartOf(context.range);
|
|
222
|
+
const fullColumns = differenceIn(startOf(date), timelineStartDate);
|
|
223
|
+
|
|
224
|
+
if (context.range === "daily") {
|
|
225
|
+
return parsedColumnWidth * fullColumns;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const partialColumns = dayjs(date).date();
|
|
229
|
+
const daysInMonth = dayjs(date).daysInMonth();
|
|
230
|
+
const pixelsPerDay = parsedColumnWidth / daysInMonth;
|
|
231
|
+
|
|
232
|
+
return fullColumns * parsedColumnWidth + partialColumns * pixelsPerDay;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const getWidth = (
|
|
236
|
+
startAt: Date,
|
|
237
|
+
endAt: Date | null,
|
|
238
|
+
context: GanttContextProps,
|
|
239
|
+
) => {
|
|
240
|
+
const parsedColumnWidth = (context.columnWidth * context.zoom) / 100;
|
|
241
|
+
|
|
242
|
+
if (!endAt) {
|
|
243
|
+
return parsedColumnWidth * 2;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const differenceIn = getDifferenceIn(context.range);
|
|
247
|
+
|
|
248
|
+
if (context.range === "daily") {
|
|
249
|
+
const delta = differenceIn(endAt, startAt);
|
|
250
|
+
return parsedColumnWidth * (delta || 1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const daysInStartMonth = dayjs(startAt).daysInMonth();
|
|
254
|
+
const pixelsPerDayInStartMonth = parsedColumnWidth / daysInStartMonth;
|
|
255
|
+
|
|
256
|
+
if (dayjs(startAt).isSame(dayjs(endAt), "day")) {
|
|
257
|
+
return pixelsPerDayInStartMonth;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const innerDifferenceIn = getInnerDifferenceIn(context.range);
|
|
261
|
+
const startOf = getStartOf(context.range);
|
|
262
|
+
|
|
263
|
+
if (dayjs(startOf(startAt)).isSame(dayjs(startOf(endAt)), "day")) {
|
|
264
|
+
return innerDifferenceIn(endAt, startAt) * pixelsPerDayInStartMonth;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const startRangeOffset = daysInStartMonth - dayjs(startAt).date();
|
|
268
|
+
const endRangeOffset = dayjs(endAt).date();
|
|
269
|
+
const fullRangeOffset = differenceIn(startOf(endAt), startOf(startAt));
|
|
270
|
+
const daysInEndMonth = dayjs(endAt).daysInMonth();
|
|
271
|
+
const pixelsPerDayInEndMonth = parsedColumnWidth / daysInEndMonth;
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
(fullRangeOffset - 1) * parsedColumnWidth +
|
|
275
|
+
startRangeOffset * pixelsPerDayInStartMonth +
|
|
276
|
+
endRangeOffset * pixelsPerDayInEndMonth
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export const calculateInnerOffset = (
|
|
281
|
+
date: Date,
|
|
282
|
+
range: GanttRange,
|
|
283
|
+
columnWidth: number,
|
|
284
|
+
) => {
|
|
285
|
+
const startOf = getStartOf(range);
|
|
286
|
+
const endOf = getEndOf(range);
|
|
287
|
+
const differenceIn = getInnerDifferenceIn(range);
|
|
288
|
+
const startOfRange = startOf(date);
|
|
289
|
+
const endOfRange = endOf(date);
|
|
290
|
+
const totalRangeDays = differenceIn(endOfRange, startOfRange);
|
|
291
|
+
const dayOfMonth = dayjs(date).date();
|
|
292
|
+
|
|
293
|
+
return (dayOfMonth / totalRangeDays) * columnWidth;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export type GanttProviderProps = {
|
|
297
|
+
range?: GanttRange;
|
|
298
|
+
zoom?: number;
|
|
299
|
+
onAddItem?: (date: Date) => void;
|
|
300
|
+
onZoomChange?: (zoom: number) => void;
|
|
301
|
+
children: ReactNode;
|
|
302
|
+
className?: string;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
export const GanttProvider: FC<GanttProviderProps> = ({
|
|
306
|
+
zoom = 100,
|
|
307
|
+
range = "monthly",
|
|
308
|
+
onAddItem,
|
|
309
|
+
onZoomChange,
|
|
310
|
+
children,
|
|
311
|
+
className,
|
|
312
|
+
}) => {
|
|
313
|
+
const classes = useGanttClasses();
|
|
314
|
+
const merge = useCn();
|
|
315
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
316
|
+
const [timelineData, setTimelineData] = useState<GanttTimelineData>(
|
|
317
|
+
createInitialTimelineData(new Date()),
|
|
318
|
+
);
|
|
319
|
+
const [scrollX, setScrollX] = useState(0);
|
|
320
|
+
const [dragging, setDragging] = useState(false);
|
|
321
|
+
const [sidebarWidth, setSidebarWidth] = useState(0);
|
|
322
|
+
|
|
323
|
+
const headerHeight = 60;
|
|
324
|
+
const rowHeight = 36;
|
|
325
|
+
// Daily is rendered as a Linear-style weekly grid (~16px/day → ~112px/week),
|
|
326
|
+
// so the per-day column is narrow and only week starts are labelled.
|
|
327
|
+
let columnWidth = 16;
|
|
328
|
+
|
|
329
|
+
if (range === "monthly") {
|
|
330
|
+
columnWidth = 150;
|
|
331
|
+
} else if (range === "quarterly") {
|
|
332
|
+
columnWidth = 100;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const cssVariables = useMemo((): CSSProperties => {
|
|
336
|
+
const vars: Record<string, string> = {
|
|
337
|
+
"--gantt-zoom": `${zoom}`,
|
|
338
|
+
"--gantt-column-width": `${(zoom / 100) * columnWidth}px`,
|
|
339
|
+
"--gantt-header-height": `${headerHeight}px`,
|
|
340
|
+
"--gantt-row-height": `${rowHeight}px`,
|
|
341
|
+
"--gantt-sidebar-width": `${sidebarWidth}px`,
|
|
342
|
+
};
|
|
343
|
+
return vars;
|
|
344
|
+
}, [zoom, columnWidth, sidebarWidth]);
|
|
345
|
+
|
|
346
|
+
// Center the canvas on today once the sidebar width is known, matching
|
|
347
|
+
// Linear's default (today near the middle of the viewport). Runs once.
|
|
348
|
+
const didCenterRef = useRef(false);
|
|
349
|
+
/* Scrolls the real element and then records where it landed; the scroll
|
|
350
|
+
position only exists after layout, so it cannot be derived during render. */
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
const el = scrollRef.current;
|
|
353
|
+
if (!el || didCenterRef.current || sidebarWidth === 0) return;
|
|
354
|
+
const firstYear = timelineData[0]?.year ?? new Date().getFullYear();
|
|
355
|
+
const offset = getOffset(new Date(), new Date(firstYear, 0, 1), {
|
|
356
|
+
zoom,
|
|
357
|
+
range,
|
|
358
|
+
columnWidth,
|
|
359
|
+
sidebarWidth,
|
|
360
|
+
headerHeight,
|
|
361
|
+
rowHeight,
|
|
362
|
+
onAddItem,
|
|
363
|
+
placeholderLength: 2,
|
|
364
|
+
timelineData,
|
|
365
|
+
ref: scrollRef,
|
|
366
|
+
dragging: false,
|
|
367
|
+
setDragging: () => {},
|
|
368
|
+
scrollX: 0,
|
|
369
|
+
setScrollX: () => {},
|
|
370
|
+
});
|
|
371
|
+
el.scrollLeft = Math.max(0, sidebarWidth + offset - el.clientWidth / 2);
|
|
372
|
+
setScrollX(el.scrollLeft);
|
|
373
|
+
didCenterRef.current = true;
|
|
374
|
+
}, [sidebarWidth, zoom, range, columnWidth, timelineData, onAddItem]);
|
|
375
|
+
|
|
376
|
+
// Ctrl/Cmd + wheel (and trackpad pinch, which sends ctrlKey) zooms the
|
|
377
|
+
// timeline like Linear, instead of zooming the page. Plain wheel scrolls.
|
|
378
|
+
const zoomRef = useRef(zoom);
|
|
379
|
+
zoomRef.current = zoom;
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
const el = scrollRef.current;
|
|
382
|
+
if (!el || !onZoomChange) return;
|
|
383
|
+
const onWheel = (event: WheelEvent) => {
|
|
384
|
+
if (!(event.ctrlKey || event.metaKey)) return;
|
|
385
|
+
event.preventDefault();
|
|
386
|
+
const direction = event.deltaY < 0 ? 1 : -1;
|
|
387
|
+
const next = Math.min(
|
|
388
|
+
200,
|
|
389
|
+
Math.max(50, zoomRef.current + direction * 10),
|
|
390
|
+
);
|
|
391
|
+
if (next !== zoomRef.current) {
|
|
392
|
+
zoomRef.current = next;
|
|
393
|
+
onZoomChange(next);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
397
|
+
return () => el.removeEventListener("wheel", onWheel);
|
|
398
|
+
}, [onZoomChange]);
|
|
399
|
+
|
|
400
|
+
/* Measures the rendered sidebar via a MutationObserver plus resize. There is
|
|
401
|
+
no snapshot to read before the first paint, so useSyncExternalStore has
|
|
402
|
+
nothing to return on mount. */
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
const updateSidebarWidth = () => {
|
|
405
|
+
const sidebarElement = scrollRef.current?.querySelector(
|
|
406
|
+
'[data-roadmap-ui="gantt-sidebar"]',
|
|
407
|
+
);
|
|
408
|
+
setSidebarWidth(sidebarElement ? ganttSidebarWidth() : 0);
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
updateSidebarWidth();
|
|
412
|
+
|
|
413
|
+
const observer = new MutationObserver(updateSidebarWidth);
|
|
414
|
+
const el = scrollRef.current;
|
|
415
|
+
if (el) {
|
|
416
|
+
observer.observe(el, { childList: true, subtree: true });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// The width is viewport-relative below the full 300px, so it has to be
|
|
420
|
+
// recomputed on resize and rotation — this number is also the scroll maths'
|
|
421
|
+
// origin, so letting it drift from the rendered track mis-centres the chart.
|
|
422
|
+
window.addEventListener("resize", updateSidebarWidth);
|
|
423
|
+
|
|
424
|
+
return () => {
|
|
425
|
+
observer.disconnect();
|
|
426
|
+
window.removeEventListener("resize", updateSidebarWidth);
|
|
427
|
+
};
|
|
428
|
+
}, []);
|
|
429
|
+
|
|
430
|
+
const handleScroll = useMemo(
|
|
431
|
+
() =>
|
|
432
|
+
throttle(() => {
|
|
433
|
+
const scrollElement = scrollRef.current;
|
|
434
|
+
if (!scrollElement) return;
|
|
435
|
+
|
|
436
|
+
const { scrollLeft, scrollWidth, clientWidth } = scrollElement;
|
|
437
|
+
setScrollX(scrollLeft);
|
|
438
|
+
|
|
439
|
+
if (scrollLeft === 0) {
|
|
440
|
+
const firstYear = timelineData[0]?.year;
|
|
441
|
+
if (!firstYear) return;
|
|
442
|
+
|
|
443
|
+
const newData: GanttTimelineData = [...timelineData];
|
|
444
|
+
const yearObj: GanttTimelineData[number] = {
|
|
445
|
+
year: firstYear - 1,
|
|
446
|
+
quarters: [],
|
|
447
|
+
};
|
|
448
|
+
for (let q = 0; q < 4; q++) {
|
|
449
|
+
const months = [];
|
|
450
|
+
for (let m = 0; m < 3; m++) {
|
|
451
|
+
const month = q * 3 + m;
|
|
452
|
+
months.push({
|
|
453
|
+
days: dayjs(new Date(firstYear - 1, month, 1)).daysInMonth(),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
yearObj.quarters.push({ months });
|
|
457
|
+
}
|
|
458
|
+
newData.unshift(yearObj);
|
|
459
|
+
setTimelineData(newData);
|
|
460
|
+
|
|
461
|
+
scrollElement.scrollLeft = scrollElement.clientWidth;
|
|
462
|
+
setScrollX(scrollElement.scrollLeft);
|
|
463
|
+
} else if (scrollLeft + clientWidth >= scrollWidth) {
|
|
464
|
+
const lastYear = timelineData.at(-1)?.year;
|
|
465
|
+
if (!lastYear) return;
|
|
466
|
+
|
|
467
|
+
const newData: GanttTimelineData = [...timelineData];
|
|
468
|
+
const yearObj: GanttTimelineData[number] = {
|
|
469
|
+
year: lastYear + 1,
|
|
470
|
+
quarters: [],
|
|
471
|
+
};
|
|
472
|
+
for (let q = 0; q < 4; q++) {
|
|
473
|
+
const months = [];
|
|
474
|
+
for (let m = 0; m < 3; m++) {
|
|
475
|
+
const month = q * 3 + m;
|
|
476
|
+
months.push({
|
|
477
|
+
days: dayjs(new Date(lastYear + 1, month, 1)).daysInMonth(),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
yearObj.quarters.push({ months });
|
|
481
|
+
}
|
|
482
|
+
newData.push(yearObj);
|
|
483
|
+
setTimelineData(newData);
|
|
484
|
+
|
|
485
|
+
scrollElement.scrollLeft =
|
|
486
|
+
scrollElement.scrollWidth - scrollElement.clientWidth;
|
|
487
|
+
setScrollX(scrollElement.scrollLeft);
|
|
488
|
+
}
|
|
489
|
+
}, 100),
|
|
490
|
+
[timelineData],
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
useEffect(() => {
|
|
494
|
+
const scrollElement = scrollRef.current;
|
|
495
|
+
if (!scrollElement) return;
|
|
496
|
+
|
|
497
|
+
scrollElement.addEventListener("scroll", handleScroll);
|
|
498
|
+
return () => scrollElement.removeEventListener("scroll", handleScroll);
|
|
499
|
+
}, [handleScroll]);
|
|
500
|
+
|
|
501
|
+
// Drag-to-pan: with the scrollbar hidden, pressing on empty canvas and
|
|
502
|
+
// dragging scrolls the timeline like Linear's roadmap. Bars, the sidebar and
|
|
503
|
+
// any interactive control opt out via the selector below so their own
|
|
504
|
+
// click/drag behaviour is preserved.
|
|
505
|
+
useEffect(() => {
|
|
506
|
+
const el = scrollRef.current;
|
|
507
|
+
if (!el) return;
|
|
508
|
+
|
|
509
|
+
let panning = false;
|
|
510
|
+
let startX = 0;
|
|
511
|
+
let startY = 0;
|
|
512
|
+
let startLeft = 0;
|
|
513
|
+
let startTop = 0;
|
|
514
|
+
|
|
515
|
+
const isInteractive = (target: EventTarget | null) =>
|
|
516
|
+
target instanceof Element &&
|
|
517
|
+
Boolean(
|
|
518
|
+
target.closest(
|
|
519
|
+
'button, a, input, textarea, select, [role="button"], [data-gantt-no-pan], [data-roadmap-ui="gantt-sidebar"]',
|
|
520
|
+
),
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
524
|
+
if (event.button !== 0 || isInteractive(event.target)) return;
|
|
525
|
+
panning = true;
|
|
526
|
+
startX = event.clientX;
|
|
527
|
+
startY = event.clientY;
|
|
528
|
+
startLeft = el.scrollLeft;
|
|
529
|
+
startTop = el.scrollTop;
|
|
530
|
+
el.style.cursor = "grabbing";
|
|
531
|
+
event.preventDefault();
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
535
|
+
if (!panning) return;
|
|
536
|
+
el.scrollLeft = startLeft - (event.clientX - startX);
|
|
537
|
+
el.scrollTop = startTop - (event.clientY - startY);
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const endPan = () => {
|
|
541
|
+
if (!panning) return;
|
|
542
|
+
panning = false;
|
|
543
|
+
el.style.cursor = "";
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
el.addEventListener("pointerdown", onPointerDown);
|
|
547
|
+
window.addEventListener("pointermove", onPointerMove);
|
|
548
|
+
window.addEventListener("pointerup", endPan);
|
|
549
|
+
window.addEventListener("pointercancel", endPan);
|
|
550
|
+
|
|
551
|
+
return () => {
|
|
552
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
553
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
554
|
+
window.removeEventListener("pointerup", endPan);
|
|
555
|
+
window.removeEventListener("pointercancel", endPan);
|
|
556
|
+
};
|
|
557
|
+
}, []);
|
|
558
|
+
|
|
559
|
+
const scrollToFeature = useCallback(
|
|
560
|
+
(feature: GanttFeature) => {
|
|
561
|
+
const scrollElement = scrollRef.current;
|
|
562
|
+
if (!scrollElement) return;
|
|
563
|
+
|
|
564
|
+
const firstYear = timelineData[0]?.year ?? new Date().getFullYear();
|
|
565
|
+
const timelineStartDate = new Date(firstYear, 0, 1);
|
|
566
|
+
|
|
567
|
+
const offset = getOffset(feature.startAt, timelineStartDate, {
|
|
568
|
+
zoom,
|
|
569
|
+
range,
|
|
570
|
+
columnWidth,
|
|
571
|
+
sidebarWidth,
|
|
572
|
+
headerHeight,
|
|
573
|
+
rowHeight,
|
|
574
|
+
onAddItem,
|
|
575
|
+
placeholderLength: 2,
|
|
576
|
+
timelineData,
|
|
577
|
+
ref: scrollRef,
|
|
578
|
+
dragging: false,
|
|
579
|
+
setDragging: () => {},
|
|
580
|
+
scrollX: 0,
|
|
581
|
+
setScrollX: () => {},
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
scrollElement.scrollTo({ left: Math.max(0, offset), behavior: "smooth" });
|
|
585
|
+
},
|
|
586
|
+
[timelineData, zoom, range, columnWidth, sidebarWidth, onAddItem],
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
const scrollToToday = useCallback(() => {
|
|
590
|
+
const scrollElement = scrollRef.current;
|
|
591
|
+
if (!scrollElement) return;
|
|
592
|
+
|
|
593
|
+
const firstYear = timelineData[0]?.year ?? new Date().getFullYear();
|
|
594
|
+
const timelineStartDate = new Date(firstYear, 0, 1);
|
|
595
|
+
|
|
596
|
+
const offset = getOffset(new Date(), timelineStartDate, {
|
|
597
|
+
zoom,
|
|
598
|
+
range,
|
|
599
|
+
columnWidth,
|
|
600
|
+
sidebarWidth,
|
|
601
|
+
headerHeight,
|
|
602
|
+
rowHeight,
|
|
603
|
+
onAddItem,
|
|
604
|
+
placeholderLength: 2,
|
|
605
|
+
timelineData,
|
|
606
|
+
ref: scrollRef,
|
|
607
|
+
dragging: false,
|
|
608
|
+
setDragging: () => {},
|
|
609
|
+
scrollX: 0,
|
|
610
|
+
setScrollX: () => {},
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Centre today in the viewport rather than pinning it to the left edge.
|
|
614
|
+
scrollElement.scrollTo({
|
|
615
|
+
left: Math.max(0, offset - scrollElement.clientWidth / 2),
|
|
616
|
+
behavior: "smooth",
|
|
617
|
+
});
|
|
618
|
+
}, [timelineData, zoom, range, columnWidth, sidebarWidth, onAddItem]);
|
|
619
|
+
|
|
620
|
+
return (
|
|
621
|
+
<GanttContext.Provider
|
|
622
|
+
value={{
|
|
623
|
+
zoom,
|
|
624
|
+
range,
|
|
625
|
+
headerHeight,
|
|
626
|
+
columnWidth,
|
|
627
|
+
sidebarWidth,
|
|
628
|
+
rowHeight,
|
|
629
|
+
onAddItem,
|
|
630
|
+
timelineData,
|
|
631
|
+
placeholderLength: 2,
|
|
632
|
+
ref: scrollRef,
|
|
633
|
+
scrollToFeature,
|
|
634
|
+
scrollToToday,
|
|
635
|
+
dragging,
|
|
636
|
+
setDragging,
|
|
637
|
+
scrollX,
|
|
638
|
+
setScrollX,
|
|
639
|
+
}}
|
|
640
|
+
>
|
|
641
|
+
<div
|
|
642
|
+
className={merge(
|
|
643
|
+
classes.root,
|
|
644
|
+
range,
|
|
645
|
+
className,
|
|
646
|
+
)}
|
|
647
|
+
ref={scrollRef}
|
|
648
|
+
style={{
|
|
649
|
+
...cssVariables,
|
|
650
|
+
gridTemplateColumns: "var(--gantt-sidebar-width) 1fr",
|
|
651
|
+
}}
|
|
652
|
+
>
|
|
653
|
+
{children}
|
|
654
|
+
</div>
|
|
655
|
+
</GanttContext.Provider>
|
|
656
|
+
);
|
|
657
|
+
};
|