@vvedantb/ui 0.17.1 → 0.18.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.
Files changed (129) hide show
  1. package/README.md +251 -3
  2. package/package.json +13 -5
  3. package/shell/src/SidebarResize.tsx +1 -4
  4. package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
  5. package/src/ai-elements/activity-question-utils.ts +26 -0
  6. package/src/ai-elements/activity-question.tsx +159 -0
  7. package/src/ai-elements/activity-shared.tsx +459 -0
  8. package/src/ai-elements/activity-step-detail.tsx +115 -0
  9. package/src/ai-elements/activity-step-label.ts +981 -0
  10. package/src/ai-elements/activity-tasks-utils.ts +199 -0
  11. package/src/ai-elements/activity-tasks.tsx +770 -0
  12. package/src/ai-elements/artifact.tsx +162 -52
  13. package/src/ai-elements/attachments.tsx +62 -32
  14. package/src/ai-elements/code-block.tsx +76 -22
  15. package/src/ai-elements/context.tsx +370 -0
  16. package/src/ai-elements/conversation.tsx +186 -62
  17. package/src/ai-elements/file-chip.tsx +53 -0
  18. package/src/ai-elements/message.tsx +161 -50
  19. package/src/ai-elements/model-picker-content.tsx +505 -0
  20. package/src/ai-elements/model-picker-types.ts +56 -0
  21. package/src/ai-elements/model-picker.tsx +301 -0
  22. package/src/ai-elements/model-selector.tsx +102 -21
  23. package/src/ai-elements/plan.tsx +130 -0
  24. package/src/ai-elements/prompt-input-settings.tsx +144 -0
  25. package/src/ai-elements/prompt-input-speech.tsx +189 -0
  26. package/src/ai-elements/prompt-input-traits.tsx +281 -0
  27. package/src/ai-elements/prompt-input.tsx +320 -134
  28. package/src/ai-elements/provider-icon.tsx +133 -0
  29. package/src/ai-elements/queue.tsx +293 -9
  30. package/src/ai-elements/shimmer.tsx +72 -1
  31. package/src/ai-elements/simple-model-ladder.tsx +108 -0
  32. package/src/ai-elements/test-results.tsx +510 -0
  33. package/src/ai-elements/trace.tsx +243 -0
  34. package/src/ai-elements/use-streaming-disclosure.ts +50 -0
  35. package/src/ai-elements/web-preview.tsx +349 -0
  36. package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
  37. package/src/index.ts +66 -12
  38. package/src/kibo/avatar-stack.tsx +40 -0
  39. package/src/kibo/contribution-graph.tsx +539 -0
  40. package/src/kibo/cursor.tsx +72 -0
  41. package/src/kibo/gantt-features.tsx +439 -0
  42. package/src/kibo/gantt-header.tsx +181 -0
  43. package/src/kibo/gantt-provider.tsx +657 -0
  44. package/src/kibo/gantt-sidebar.tsx +157 -0
  45. package/src/kibo/gantt-timeline.tsx +154 -0
  46. package/src/kibo/index.ts +151 -0
  47. package/src/kibo/kanban.tsx +338 -0
  48. package/src/kibo/kanbanDropTarget.ts +25 -0
  49. package/src/kibo/list.tsx +162 -0
  50. package/src/kibo/theme-switcher.tsx +109 -0
  51. package/src/kibo/video-player.tsx +237 -0
  52. package/src/markdown/Markdown.tsx +112 -0
  53. package/src/markdown/MarkdownBlockquote.tsx +45 -0
  54. package/src/markdown/MarkdownCodeBlock.tsx +197 -0
  55. package/src/markdown/MarkdownInlineCode.tsx +40 -0
  56. package/src/markdown/MarkdownLink.tsx +92 -0
  57. package/src/markdown/codeFence.ts +57 -0
  58. package/src/markdown/codeHighlight.ts +138 -0
  59. package/src/markdown/incrementalBlocks.ts +182 -0
  60. package/src/markdown/index.ts +3 -0
  61. package/src/markdown/markdownClipboard.ts +178 -0
  62. package/src/markdown/mermaid.ts +155 -0
  63. package/src/markdown/rehypeGithubAlerts.ts +80 -0
  64. package/src/motion/presets.ts +24 -0
  65. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
  66. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
  67. package/src/ui/_menu-parts.tsx +29 -0
  68. package/src/ui/accordion.alt.tsx +46 -53
  69. package/src/ui/accordion.tsx +59 -10
  70. package/src/ui/avatar.tsx +56 -0
  71. package/src/ui/badge.tsx +45 -21
  72. package/src/ui/border-beam.tsx +97 -0
  73. package/src/ui/button-group.tsx +50 -21
  74. package/src/ui/button.tsx +67 -45
  75. package/src/ui/calendar.tsx +48 -5
  76. package/src/ui/card.tsx +77 -8
  77. package/src/ui/carousel.tsx +60 -0
  78. package/src/ui/centered-spinner.tsx +30 -0
  79. package/src/ui/checkbox.tsx +34 -16
  80. package/src/ui/clear-input.alt.tsx +33 -75
  81. package/src/ui/clear-input.tsx +140 -15
  82. package/src/ui/collapsible.tsx +40 -2
  83. package/src/ui/command.tsx +143 -70
  84. package/src/ui/context-menu.tsx +159 -93
  85. package/src/ui/crossfade-icon.tsx +123 -0
  86. package/src/ui/dialog.tsx +154 -70
  87. package/src/ui/dropdown-menu.tsx +177 -117
  88. package/src/ui/hover-card.tsx +59 -17
  89. package/src/ui/input-group.alt.tsx +83 -83
  90. package/src/ui/input-group.tsx +129 -58
  91. package/src/ui/input.tsx +13 -4
  92. package/src/ui/label.tsx +11 -8
  93. package/src/ui/list-row.tsx +232 -0
  94. package/src/ui/loading-state.tsx +215 -0
  95. package/src/ui/popover.tsx +56 -23
  96. package/src/ui/progress.tsx +28 -16
  97. package/src/ui/refresh-spin-icon.tsx +41 -0
  98. package/src/ui/search-input.tsx +66 -15
  99. package/src/ui/select.tsx +171 -100
  100. package/src/ui/separator.tsx +23 -14
  101. package/src/ui/slider.tsx +21 -7
  102. package/src/ui/sonner.tsx +68 -30
  103. package/src/ui/spinner.tsx +229 -11
  104. package/src/ui/status-dot.tsx +84 -0
  105. package/src/ui/surface.tsx +49 -0
  106. package/src/ui/switch.tsx +29 -15
  107. package/src/ui/table.tsx +38 -16
  108. package/src/ui/tabs.tsx +123 -35
  109. package/src/ui/tabsSliding.ts +33 -1
  110. package/src/ui/textarea.tsx +13 -4
  111. package/src/ui/tooltip.tsx +50 -14
  112. package/src/ui/ui-config.tsx +149 -0
  113. package/src/utils/cat-color.ts +123 -0
  114. package/src/utils/dayjs.ts +6 -0
  115. package/src/utils/gesture.ts +59 -0
  116. package/src/utils/runtimeVisibility.ts +167 -0
  117. package/src/utils/sharedClock.ts +96 -0
  118. package/src/utils/surface-radius.ts +16 -0
  119. package/src/utils/useDragSensors.ts +58 -0
  120. package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
  121. package/tokens/examples/tokens-moved/README.md +20 -0
  122. package/tokens/examples/tokens-moved/index.html +12 -0
  123. package/tokens/examples/tokens-moved/main.tsx +6 -0
  124. package/tokens/examples/tokens-moved/package.json +14 -0
  125. package/tokens/examples/tokens-moved/styles.css +12 -0
  126. package/tokens/examples/tokens-moved/tsconfig.json +14 -0
  127. package/tokens/examples/tokens-moved/vite.config.ts +11 -0
  128. package/tokens/src/css/border-beam.css +116 -0
  129. package/tokens/src/css/markdown.css +318 -0
@@ -0,0 +1,439 @@
1
+ "use client";
2
+
3
+ import dayjs from "../utils/dayjs";
4
+ import { DndContext, useDraggable } from "@dnd-kit/core";
5
+ import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
6
+ import type { FC, ReactNode } from "react";
7
+ import { useCallback, useEffect, useMemo, useState } from "react";
8
+ import { useCn } from "../ui/ui-config";
9
+ import {
10
+ CONTROL_RADIUS_CLASS,
11
+ SURFACE_RADIUS_CLASS,
12
+ } from "../utils/surface-radius";
13
+ import {
14
+ useGanttContext,
15
+ useGanttClasses,
16
+ getAddRange,
17
+ getOffset,
18
+ getWidth,
19
+ type GanttFeature,
20
+ } from "./gantt-provider";
21
+ import { useDragSensors } from "../utils/useDragSensors";
22
+
23
+ export type GanttFeatureDragHelperProps = {
24
+ featureId: GanttFeature["id"];
25
+ direction: "left" | "right";
26
+ date: Date | null;
27
+ };
28
+
29
+ export const GanttFeatureDragHelper: FC<GanttFeatureDragHelperProps> = ({
30
+ direction,
31
+ featureId,
32
+ date,
33
+ }) => {
34
+ const gantt = useGanttContext();
35
+ const classes = useGanttClasses();
36
+ const merge = useCn();
37
+ const { attributes, listeners, setNodeRef } = useDraggable({
38
+ id: `feature-drag-helper-${featureId}`,
39
+ });
40
+
41
+ const isPressed = Boolean(attributes["aria-pressed"]);
42
+
43
+ useEffect(() => gantt.setDragging(isPressed), [isPressed, gantt]);
44
+
45
+ return (
46
+ <div
47
+ className={merge(
48
+ "group -translate-y-1/2 cursor-col-resize absolute top-1/2 z-3 h-full w-6 rounded-md outline-hidden",
49
+ direction === "left" ? "-left-2.5" : "-right-2.5",
50
+ )}
51
+ data-gantt-no-pan
52
+ ref={setNodeRef}
53
+ {...attributes}
54
+ {...listeners}
55
+ >
56
+ <div
57
+ className={merge(
58
+ // The handle slides out on hover via translate, not `left`/`right`:
59
+ // an inset animation relayouts every frame, a transform does not.
60
+ // Only opacity and the slide transition, so this stays on the
61
+ // compositor while the user is mid-drag.
62
+ //
63
+ // The named property is `translate`, not `transform`: the slide is
64
+ // `translate-x-*`, which Tailwind compiles to `translate: …`, and an
65
+ // arbitrary `transition-[…]` matches property names literally — so
66
+ // the handle used to appear at its final offset in one frame.
67
+ classes.dragHandle,
68
+ direction === "left" ? "left-0" : "right-0",
69
+ direction === "left" ? "translate-x-2.5" : "-translate-x-2.5",
70
+ "group-hover:translate-x-0",
71
+ isPressed && "translate-x-0",
72
+ "group-hover:opacity-100",
73
+ isPressed && "opacity-100",
74
+ )}
75
+ />
76
+ {date && (
77
+ <div
78
+ className={merge(
79
+ "-translate-x-1/2 absolute top-10 hidden whitespace-nowrap bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg group-hover:block",
80
+ SURFACE_RADIUS_CLASS,
81
+ isPressed && "block",
82
+ )}
83
+ >
84
+ {dayjs(date).format("MMM DD, YYYY")}
85
+ </div>
86
+ )}
87
+ </div>
88
+ );
89
+ };
90
+
91
+ export type GanttFeatureItemCardProps = Pick<GanttFeature, "id"> & {
92
+ children?: ReactNode;
93
+ className?: string;
94
+ onClick?: () => void;
95
+ };
96
+
97
+ export const GanttFeatureItemCard: FC<GanttFeatureItemCardProps> = ({
98
+ id,
99
+ children,
100
+ className,
101
+ onClick,
102
+ }) => {
103
+ const gantt = useGanttContext();
104
+ const merge = useCn();
105
+ const { attributes, listeners, setNodeRef } = useDraggable({ id });
106
+ const isPressed = Boolean(attributes["aria-pressed"]);
107
+
108
+ useEffect(() => gantt.setDragging(isPressed), [isPressed, gantt]);
109
+
110
+ return (
111
+ <div
112
+ data-gantt-no-pan
113
+ className={merge(
114
+ "relative h-full w-full overflow-hidden border border-border/70 text-xs",
115
+ CONTROL_RADIUS_CLASS,
116
+ className,
117
+ )}
118
+ >
119
+ <div
120
+ className={merge(
121
+ "h-full w-full",
122
+ isPressed ? "cursor-grabbing" : "cursor-grab",
123
+ )}
124
+ {...attributes}
125
+ {...listeners}
126
+ onClick={onClick}
127
+ ref={setNodeRef}
128
+ >
129
+ {children}
130
+ </div>
131
+ </div>
132
+ );
133
+ };
134
+
135
+ export type GanttFeatureItemProps = GanttFeature & {
136
+ onMove?: (id: string, startDate: Date, endDate: Date | null) => void;
137
+ onClick?: () => void;
138
+ children?: ReactNode;
139
+ className?: string;
140
+ };
141
+
142
+ export const GanttFeatureItem: FC<GanttFeatureItemProps> = ({
143
+ onMove,
144
+ onClick,
145
+ children,
146
+ className,
147
+ ...feature
148
+ }) => {
149
+ const gantt = useGanttContext();
150
+ const classes = useGanttClasses();
151
+ const merge = useCn();
152
+ const timelineStartDate = useMemo(() => {
153
+ const firstYear = gantt.timelineData[0]?.year ?? new Date().getFullYear();
154
+ return new Date(firstYear, 0, 1);
155
+ }, [gantt.timelineData]);
156
+
157
+ const [startAt, setStartAt] = useState<Date>(feature.startAt);
158
+ const [endAt, setEndAt] = useState<Date | null>(feature.endAt);
159
+
160
+ const width = useMemo(
161
+ () => getWidth(startAt, endAt, gantt),
162
+ [startAt, endAt, gantt],
163
+ );
164
+ const offset = useMemo(
165
+ () => getOffset(startAt, timelineStartDate, gantt),
166
+ [startAt, timelineStartDate, gantt],
167
+ );
168
+
169
+ const addRange = useMemo(() => getAddRange(gantt.range), [gantt.range]);
170
+
171
+ // Pixels per day for the active range, so drag deltas map to dates correctly
172
+ // in every zoom (daily columns are per-day; monthly/quarterly are per-month).
173
+ const pxPerDay = useMemo(() => {
174
+ const colW = (gantt.columnWidth * gantt.zoom) / 100;
175
+ return gantt.range === "daily" ? colW : colW / 30;
176
+ }, [gantt.columnWidth, gantt.zoom, gantt.range]);
177
+
178
+ const [, setPreviousMouseX] = useState(0);
179
+ const [previousStartAt, setPreviousStartAt] = useState(startAt);
180
+ const [previousEndAt, setPreviousEndAt] = useState(endAt);
181
+
182
+ // A `MouseSensor` alone meant a gantt bar could not be moved or resized by
183
+ // touch or pen at all — the three `DndContext`s below never armed off a
184
+ // finger.
185
+ const sensors = useDragSensors();
186
+
187
+ const handleItemDragStart = useCallback(() => {
188
+ const mouseX = gantt.scrollX;
189
+ setPreviousMouseX(mouseX);
190
+ setPreviousStartAt(startAt);
191
+ setPreviousEndAt(endAt);
192
+ }, [gantt.scrollX, gantt.ref, startAt, endAt]);
193
+
194
+ const handleItemDragMove = useCallback(
195
+ (event: { delta: { x: number } }) => {
196
+ const daysDelta = Math.round(event.delta.x / pxPerDay);
197
+ const newStartDate = dayjs(previousStartAt)
198
+ .add(daysDelta, "day")
199
+ .toDate();
200
+ const newEndDate = previousEndAt
201
+ ? dayjs(previousEndAt).add(daysDelta, "day").toDate()
202
+ : null;
203
+
204
+ setStartAt(newStartDate);
205
+ setEndAt(newEndDate);
206
+ },
207
+ [pxPerDay, previousStartAt, previousEndAt],
208
+ );
209
+
210
+ const onDragEnd = useCallback(
211
+ () => onMove?.(feature.id, startAt, endAt),
212
+ [onMove, feature.id, startAt, endAt],
213
+ );
214
+
215
+ const handleLeftDragMove = useCallback(
216
+ (event: { delta: { x: number } }) => {
217
+ const daysDelta = Math.round(event.delta.x / pxPerDay);
218
+ const newStartAt = dayjs(feature.startAt).add(daysDelta, "day").toDate();
219
+ setStartAt(newStartAt);
220
+ },
221
+ [pxPerDay, feature.startAt],
222
+ );
223
+
224
+ const handleRightDragMove = useCallback(
225
+ (event: { delta: { x: number } }) => {
226
+ const daysDelta = Math.round(event.delta.x / pxPerDay);
227
+ const newEndAt = dayjs(feature.endAt).add(daysDelta, "day").toDate();
228
+ setEndAt(newEndAt);
229
+ },
230
+ [pxPerDay, feature.endAt],
231
+ );
232
+
233
+ // When the bar sits outside the visible canvas, show a clickable date chip
234
+ // pinned to the viewport edge that scrolls back to it (Linear's "← date").
235
+ const viewportWidth = gantt.ref?.current?.clientWidth ?? 0;
236
+ const visibleEnd =
237
+ gantt.scrollX + Math.max(0, viewportWidth - gantt.sidebarWidth);
238
+ const roundedOffset = Math.round(offset);
239
+ const roundedWidth = Math.round(width);
240
+ const offscreenLeft =
241
+ viewportWidth > 0 && roundedOffset + roundedWidth < gantt.scrollX;
242
+ const offscreenRight = viewportWidth > 0 && roundedOffset > visibleEnd;
243
+ const dateLabel = endAt
244
+ ? `${dayjs(startAt).format("MMM D")} – ${dayjs(endAt).format("MMM D")}`
245
+ : dayjs(startAt).format("MMM D");
246
+ const indicatorClass = classes.offscreenIndicator;
247
+
248
+ return (
249
+ <div
250
+ className={merge("relative flex w-max min-w-full py-0.5", className)}
251
+ style={{ height: "var(--gantt-row-height)" }}
252
+ >
253
+ <div
254
+ className="pointer-events-auto absolute top-0.5"
255
+ style={{
256
+ height: "calc(var(--gantt-row-height) - 4px)",
257
+ width: Math.round(width),
258
+ left: Math.round(offset),
259
+ }}
260
+ >
261
+ {onMove && (
262
+ <DndContext
263
+ modifiers={[restrictToHorizontalAxis]}
264
+ onDragEnd={onDragEnd}
265
+ onDragMove={handleLeftDragMove}
266
+ sensors={sensors}
267
+ >
268
+ <GanttFeatureDragHelper
269
+ date={startAt}
270
+ direction="left"
271
+ featureId={feature.id}
272
+ />
273
+ </DndContext>
274
+ )}
275
+ <DndContext
276
+ modifiers={[restrictToHorizontalAxis]}
277
+ onDragEnd={onDragEnd}
278
+ onDragMove={handleItemDragMove}
279
+ onDragStart={handleItemDragStart}
280
+ sensors={sensors}
281
+ >
282
+ <GanttFeatureItemCard id={feature.id} onClick={onClick}>
283
+ {children ?? (
284
+ <div className="flex h-full w-full items-center bg-surface-secondary/40 px-2">
285
+ <p className="flex-1 truncate text-xs">{feature.name}</p>
286
+ </div>
287
+ )}
288
+ </GanttFeatureItemCard>
289
+ </DndContext>
290
+ {onMove && (
291
+ <DndContext
292
+ modifiers={[restrictToHorizontalAxis]}
293
+ onDragEnd={onDragEnd}
294
+ onDragMove={handleRightDragMove}
295
+ sensors={sensors}
296
+ >
297
+ <GanttFeatureDragHelper
298
+ date={endAt ?? addRange(startAt, 2)}
299
+ direction="right"
300
+ featureId={feature.id}
301
+ />
302
+ </DndContext>
303
+ )}
304
+ </div>
305
+ {offscreenLeft && (
306
+ <button
307
+ type="button"
308
+ data-gantt-no-pan
309
+ onClick={() => gantt.scrollToFeature?.(feature)}
310
+ className={indicatorClass}
311
+ style={{ left: gantt.scrollX + 8 }}
312
+ >
313
+ <span aria-hidden>←</span>
314
+ {dateLabel}
315
+ </button>
316
+ )}
317
+ {offscreenRight && (
318
+ <button
319
+ type="button"
320
+ data-gantt-no-pan
321
+ onClick={() => gantt.scrollToFeature?.(feature)}
322
+ className={merge(indicatorClass, "-translate-x-full")}
323
+ style={{
324
+ left:
325
+ gantt.scrollX +
326
+ Math.max(0, viewportWidth - gantt.sidebarWidth) -
327
+ 8,
328
+ }}
329
+ >
330
+ {dateLabel}
331
+ <span aria-hidden>→</span>
332
+ </button>
333
+ )}
334
+ </div>
335
+ );
336
+ };
337
+
338
+ export type GanttFeatureListGroupProps = {
339
+ children: ReactNode;
340
+ className?: string;
341
+ };
342
+
343
+ export const GanttFeatureListGroup: FC<GanttFeatureListGroupProps> = ({
344
+ children,
345
+ className,
346
+ }) => (
347
+ <div className={className} style={{ paddingTop: "var(--gantt-row-height)" }}>
348
+ {children}
349
+ </div>
350
+ );
351
+
352
+ export type GanttFeatureRowProps = {
353
+ features: GanttFeature[];
354
+ onMove?: (id: string, startAt: Date, endAt: Date | null) => void;
355
+ children?: (feature: GanttFeature) => ReactNode;
356
+ className?: string;
357
+ };
358
+
359
+ export const GanttFeatureRow: FC<GanttFeatureRowProps> = ({
360
+ features,
361
+ onMove,
362
+ children,
363
+ className,
364
+ }) => {
365
+ const sortedFeatures = [...features].sort(
366
+ (a, b) => a.startAt.getTime() - b.startAt.getTime(),
367
+ );
368
+
369
+ const featureWithPositions: (GanttFeature & { subRow: number })[] = [];
370
+ const subRowEndTimes: Date[] = [];
371
+
372
+ for (const feature of sortedFeatures) {
373
+ let subRow = 0;
374
+ while ((subRowEndTimes[subRow] ?? feature.startAt) > feature.startAt) {
375
+ subRow++;
376
+ }
377
+
378
+ if (subRow === subRowEndTimes.length) {
379
+ subRowEndTimes.push(feature.endAt);
380
+ } else {
381
+ subRowEndTimes[subRow] = feature.endAt;
382
+ }
383
+
384
+ featureWithPositions.push({ ...feature, subRow });
385
+ }
386
+
387
+ const merge = useCn();
388
+ const maxSubRows = Math.max(1, subRowEndTimes.length);
389
+ const subRowHeight = 36;
390
+
391
+ return (
392
+ <div
393
+ className={merge("relative", className)}
394
+ style={{
395
+ height: `${maxSubRows * subRowHeight}px`,
396
+ minHeight: "var(--gantt-row-height)",
397
+ }}
398
+ >
399
+ {featureWithPositions.map((feature) => (
400
+ <div
401
+ className="absolute w-full"
402
+ key={feature.id}
403
+ style={{
404
+ top: `${feature.subRow * subRowHeight}px`,
405
+ height: `${subRowHeight}px`,
406
+ }}
407
+ >
408
+ <GanttFeatureItem {...feature} onMove={onMove}>
409
+ {children ? (
410
+ children(feature)
411
+ ) : (
412
+ <p className="flex-1 truncate text-xs">{feature.name}</p>
413
+ )}
414
+ </GanttFeatureItem>
415
+ </div>
416
+ ))}
417
+ </div>
418
+ );
419
+ };
420
+
421
+ export type GanttFeatureListProps = {
422
+ className?: string;
423
+ children: ReactNode;
424
+ };
425
+
426
+ export const GanttFeatureList: FC<GanttFeatureListProps> = ({
427
+ className,
428
+ children,
429
+ }) => {
430
+ const merge = useCn();
431
+ return (
432
+ <div
433
+ className={merge("absolute top-0 left-0 h-full w-max", className)}
434
+ style={{ marginTop: "var(--gantt-header-height)" }}
435
+ >
436
+ {children}
437
+ </div>
438
+ );
439
+ };
@@ -0,0 +1,181 @@
1
+ "use client";
2
+
3
+ import dayjs from "../utils/dayjs";
4
+ import type { FC, ReactNode } from "react";
5
+ import { useId } from "react";
6
+ import { useCn } from "../ui/ui-config";
7
+ import { useGanttContext, type GanttRange } from "./gantt-provider";
8
+ import { GanttColumns } from "./gantt-timeline";
9
+
10
+ export type GanttContentHeaderProps = {
11
+ renderHeaderItem: (index: number) => ReactNode;
12
+ title: string;
13
+ columns: number;
14
+ /** Marks the column matching "now" so it can be visually emphasised. */
15
+ isColumnCurrent?: (index: number) => boolean;
16
+ /** When provided, only flagged columns get a left divider (e.g. week starts);
17
+ * otherwise every column gets a right divider. */
18
+ isColumnDivider?: (index: number) => boolean;
19
+ };
20
+
21
+ export const GanttContentHeader: FC<GanttContentHeaderProps> = ({
22
+ title,
23
+ columns,
24
+ renderHeaderItem,
25
+ isColumnCurrent,
26
+ isColumnDivider,
27
+ }) => {
28
+ const id = useId();
29
+ const merge = useCn();
30
+
31
+ return (
32
+ <div
33
+ className="sticky top-0 z-20 grid w-full shrink-0 border-b border-border bg-background/95 backdrop-blur-xs"
34
+ style={{ height: "var(--gantt-header-height)" }}
35
+ >
36
+ <div>
37
+ <div
38
+ className="sticky inline-flex whitespace-nowrap px-3 py-2 font-medium text-foreground/70 text-xs"
39
+ style={{ left: "var(--gantt-sidebar-width)" }}
40
+ >
41
+ <p>{title}</p>
42
+ </div>
43
+ </div>
44
+ <div
45
+ className="grid w-full"
46
+ style={{
47
+ gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`,
48
+ }}
49
+ >
50
+ {Array.from({ length: columns }).map((_, index) => (
51
+ <div
52
+ className={merge(
53
+ "shrink-0 py-1 text-center text-xs text-muted-foreground",
54
+ isColumnDivider
55
+ ? isColumnDivider(index)
56
+ ? "border-l border-border/60"
57
+ : ""
58
+ : "border-r border-border/50",
59
+ isColumnCurrent?.(index) &&
60
+ "bg-primary/5 font-medium text-foreground",
61
+ )}
62
+ key={`${id}-${index}`}
63
+ >
64
+ {renderHeaderItem(index)}
65
+ </div>
66
+ ))}
67
+ </div>
68
+ </div>
69
+ );
70
+ };
71
+
72
+ const DailyHeader: FC = () => {
73
+ const gantt = useGanttContext();
74
+ const now = dayjs();
75
+
76
+ return gantt.timelineData.map((year) =>
77
+ year.quarters
78
+ .flatMap((quarter) => quarter.months)
79
+ .map((month, index) => {
80
+ const monthStart = new Date(year.year, index, 1);
81
+ // Linear's grid is weekly: label + divide only at week starts (Sundays).
82
+ const isWeekStart = (item: number) =>
83
+ dayjs(monthStart).add(item, "day").day() === 0;
84
+ return (
85
+ <div className="relative flex flex-col" key={`${year.year}-${index}`}>
86
+ <GanttContentHeader
87
+ columns={month.days}
88
+ isColumnDivider={isWeekStart}
89
+ isColumnCurrent={(item) =>
90
+ now.year() === year.year &&
91
+ now.month() === index &&
92
+ now.date() === item + 1
93
+ }
94
+ renderHeaderItem={(item: number) =>
95
+ isWeekStart(item) ? (
96
+ <span>{dayjs(monthStart).add(item, "day").format("D")}</span>
97
+ ) : null
98
+ }
99
+ title={dayjs(monthStart).format("MMMM YYYY")}
100
+ />
101
+ <GanttColumns columns={month.days} isColumnDivider={isWeekStart} />
102
+ </div>
103
+ );
104
+ }),
105
+ );
106
+ };
107
+
108
+ const MonthlyHeader: FC = () => {
109
+ const gantt = useGanttContext();
110
+ const now = dayjs();
111
+
112
+ return gantt.timelineData.map((year) => (
113
+ <div className="relative flex flex-col" key={year.year}>
114
+ <GanttContentHeader
115
+ columns={year.quarters.flatMap((quarter) => quarter.months).length}
116
+ isColumnCurrent={(item) =>
117
+ now.year() === year.year && now.month() === item
118
+ }
119
+ renderHeaderItem={(item: number) => (
120
+ <p>{dayjs(new Date(year.year, item, 1)).format("MMM")}</p>
121
+ )}
122
+ title={`${year.year}`}
123
+ />
124
+ <GanttColumns
125
+ columns={year.quarters.flatMap((quarter) => quarter.months).length}
126
+ />
127
+ </div>
128
+ ));
129
+ };
130
+
131
+ const QuarterlyHeader: FC = () => {
132
+ const gantt = useGanttContext();
133
+ const now = dayjs();
134
+
135
+ return gantt.timelineData.map((year) =>
136
+ year.quarters.map((quarter, quarterIndex) => (
137
+ <div
138
+ className="relative flex flex-col"
139
+ key={`${year.year}-${quarterIndex}`}
140
+ >
141
+ <GanttContentHeader
142
+ columns={quarter.months.length}
143
+ isColumnCurrent={(item) =>
144
+ now.year() === year.year && now.month() === quarterIndex * 3 + item
145
+ }
146
+ renderHeaderItem={(item: number) => (
147
+ <p>
148
+ {dayjs(new Date(year.year, quarterIndex * 3 + item, 1)).format(
149
+ "MMM",
150
+ )}
151
+ </p>
152
+ )}
153
+ title={`Q${quarterIndex + 1} ${year.year}`}
154
+ />
155
+ <GanttColumns columns={quarter.months.length} />
156
+ </div>
157
+ )),
158
+ );
159
+ };
160
+
161
+ const headers: Record<GanttRange, FC> = {
162
+ daily: DailyHeader,
163
+ monthly: MonthlyHeader,
164
+ quarterly: QuarterlyHeader,
165
+ };
166
+
167
+ export type GanttHeaderProps = {
168
+ className?: string;
169
+ };
170
+
171
+ export const GanttHeader: FC<GanttHeaderProps> = ({ className }) => {
172
+ const gantt = useGanttContext();
173
+ const merge = useCn();
174
+ const Header = headers[gantt.range];
175
+
176
+ return (
177
+ <div className={merge("-space-x-px flex h-full w-max", className)}>
178
+ <Header />
179
+ </div>
180
+ );
181
+ };