Gantt
ganttuiInteractive Gantt chart primitive with multi-scale timeline (day/week/month/year), collapsible task tree, progress fill, milestone markers, dependency lines, and right-click GanttContextMenu.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/gantt.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/gantt.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/gantt.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/gantt.jsonManifest:
https://uipkge.dev/r/svelte/gantt.jsonInstalls to:src/lib/components/ui/gantt/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
tasks | GanttTask[] | — | required |
scale | GanttScale | day | optional |
startDate | string | — | optional |
endDate | string | — | optional |
rowHeight | number | 40 | optional |
headerHeight | number | 48 | optional |
treeWidth | number | 280 | optional |
onscalechange | (scale: GanttScale) => void | — | optional |
ontaskclick | (task: GanttTask) => void | — | optional |
ontaskchange | (task: GanttTask) => void | — | optional |
ref | HTMLDivElement | null | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
GanttContextinterface GanttContext {
scale: GanttScale
/** React-parity setter (the Svelte-idiomatic path is assigning `scale`). */
setScale: (scale: GanttScale) => void
startDate: Date
endDate: Date
totalDays: number
columnWidth: number
rowHeight: number
headerHeight: number
treeWidth: number
tasks: GanttTask[]
onTaskClick: (task: GanttTask) => void
onTaskChange: (task: GanttTask) => void
toggleExpand?: (id: string) => void
}GanttDependencyinterface GanttDependency {
fromId: string
toId: string
type?: 'finish-to-start' | 'start-to-start' | 'finish-to-finish'
}GanttAssigneeinterface GanttAssignee {
name: string
avatar?: string
initials?: string
role?: string
}GanttTaskinterface GanttTask {
id: string
name: string
startDate: string // YYYY-MM-DD
endDate: string // YYYY-MM-DD
progress?: number // 0 to 100
color?: string
status?: GanttTaskStatus
priority?: GanttTaskPriority
assignee?: GanttAssignee
isMilestone?: boolean
isGroup?: boolean
parentId?: string | null
isExpanded?: boolean
dependencies?: string[] // task IDs
children?: GanttTask[]
}GanttColumninterface GanttColumn {
key: string
label: string
width?: string
}npm dependencies
Includes
Used by
Files installed (10)
src/lib/components/ui/gantt/Gantt.svelte3.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { GanttScale, GanttTask } from './types' export interface GanttProps extends HTMLAttributes<HTMLDivElement> { tasks: GanttTask[] scale?: GanttScale startDate?: string endDate?: string rowHeight?: number headerHeight?: number treeWidth?: number onscalechange?: (scale: GanttScale) => void ontaskclick?: (task: GanttTask) => void ontaskchange?: (task: GanttTask) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { setContext, untrack } from 'svelte' import { cn } from '$lib/utils' import { GANTT_CONTEXT_KEY } from './context' let { class: className, tasks, scale = 'day', startDate, endDate, rowHeight = 40, headerHeight = 48, treeWidth = 280, onscalechange, ontaskclick, ontaskchange, children, ref = $bindable(null), ...restProps }: GanttProps = $props() // `untrack` — mirrors the Vue twin: the prop seeds initial state only, // later scale changes flow through context + `onscalechange`. let currentScale = $state<GanttScale>(untrack(() => scale)) // Compute start and end dates from tasks if not explicitly provided const resolvedStartDate = $derived.by(() => { if (startDate) return new Date(startDate) if (tasks.length === 0) return new Date() const dates = tasks.map((t) => new Date(t.startDate).getTime()) const min = Math.min(...dates) const d = new Date(min) d.setDate(d.getDate() - 3) // padding return d }) const resolvedEndDate = $derived.by(() => { if (endDate) return new Date(endDate) if (tasks.length === 0) { const d = new Date() d.setDate(d.getDate() + 30) return d } const dates = tasks.map((t) => new Date(t.endDate).getTime()) const max = Math.max(...dates) const d = new Date(max) d.setDate(d.getDate() + 7) // padding return d }) const totalDays = $derived.by(() => { const diff = resolvedEndDate.getTime() - resolvedStartDate.getTime() return Math.max(1, Math.ceil(diff / (1000 * 60 * 60 * 24))) }) const columnWidth = $derived.by(() => { switch (currentScale) { case 'day': return 44 case 'week': return 120 case 'month': return 180 case 'year': return 240 default: return 44 } }) function applyScale(v: GanttScale) { currentScale = v onscalechange?.(v) } setContext(GANTT_CONTEXT_KEY, { get scale() { return currentScale }, set scale(v: GanttScale) { applyScale(v) }, setScale: applyScale, get startDate() { return resolvedStartDate }, get endDate() { return resolvedEndDate }, get totalDays() { return totalDays }, get columnWidth() { return columnWidth }, get rowHeight() { return rowHeight }, get headerHeight() { return headerHeight }, get treeWidth() { return treeWidth }, get tasks() { return tasks }, onTaskClick: (task: GanttTask) => ontaskclick?.(task), onTaskChange: (task: GanttTask) => ontaskchange?.(task), }) </script> <div bind:this={ref} data-uipkge data-slot="gantt" class={cn( 'border-border bg-card text-card-foreground relative flex w-full flex-col overflow-hidden rounded-xl border shadow-xs', className, )} {...restProps} > {@render children?.()} </div>src/lib/components/ui/gantt/GanttHeader.svelte2.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type { GanttScale } from './types' export interface GanttHeaderProps extends HTMLAttributes<HTMLDivElement> { title?: string showScaleSwitcher?: boolean showNavigation?: boolean /** Extra header actions (replaces the Vue `actions` slot). */ actions?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { Calendar } from '@lucide/svelte' import { cn } from '$lib/utils' import { Button, ButtonGroup } from '$lib/components/ui/button' import { getGanttContext } from './context' let { class: className, title = 'Project Timeline', showScaleSwitcher = true, showNavigation = false, actions, ref = $bindable(null), ...restProps }: GanttHeaderProps = $props() // `showNavigation` is accepted for Vue-twin parity but unused (also unused // there). Destructured so it stays out of `...restProps`. const ctx = getGanttContext() function setScale(scale: GanttScale) { if (ctx) { ctx.scale = scale } } </script> <div bind:this={ref} data-uipkge data-slot="gantt-header" class={cn('border-border bg-muted/30 flex items-center justify-between border-b px-4 py-2.5', className)} {...restProps} > <div class="flex items-center gap-2"> <Calendar class="text-primary size-4" /> <span class="text-foreground text-sm font-semibold">{title}</span> </div> <div class="flex items-center gap-3"> {@render actions?.()} {#if showScaleSwitcher && ctx} <ButtonGroup> <Button size="xs" variant={ctx.scale === 'day' ? 'default' : 'outline'} onclick={() => setScale('day')}> Day </Button> <Button size="xs" variant={ctx.scale === 'week' ? 'default' : 'outline'} onclick={() => setScale('week')}> Week </Button> <Button size="xs" variant={ctx.scale === 'month' ? 'default' : 'outline'} onclick={() => setScale('month')}> Month </Button> <Button size="xs" variant={ctx.scale === 'year' ? 'default' : 'outline'} onclick={() => setScale('year')}> Year </Button> </ButtonGroup> {/if} </div> </div>src/lib/components/ui/gantt/GanttTree.svelte4.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface GanttTreeProps extends HTMLAttributes<HTMLDivElement> { showAssignee?: boolean showPriority?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { ChevronDown, ChevronRight, Flag } from '@lucide/svelte' import { cn } from '$lib/utils' import { getGanttContext } from './context' let { class: className, showAssignee = true, showPriority = true, ref = $bindable(null), ...restProps }: GanttTreeProps = $props() // `showAssignee` is accepted for Vue-twin parity but unused (also unused // there). Destructured so it stays out of `...restProps`. const ctx = getGanttContext() const priorityColors: Record<string, string> = { urgent: 'text-destructive', high: 'text-amber-500', medium: 'text-primary', low: 'text-muted-foreground/60', } function calculateDays(startDate: string, endDate: string) { const diff = new Date(endDate).getTime() - new Date(startDate).getTime() const days = Math.max(1, Math.ceil(diff / (1000 * 60 * 60 * 24))) return `${days}d` } </script> <div bind:this={ref} data-uipkge data-slot="gantt-tree" style={`width: ${ctx?.treeWidth ?? 300}px`} class={cn('border-border bg-card flex shrink-0 flex-col border-r transition-[width] select-none', className)} {...restProps} > <!-- Column Headers --> <div style={`height: ${ctx?.headerHeight ?? 48}px`} class="border-border bg-muted/20 text-muted-foreground flex items-center justify-between border-b px-3 text-xs font-semibold tracking-wider uppercase" > <span class="flex-1 truncate">Deliverable</span> {#if showPriority} <span class="w-12 shrink-0 text-center">Pri</span> {/if} <span class="w-16 shrink-0 text-right">Duration</span> </div> <!-- Rows List --> <div class="divide-border/40 flex-1 divide-y overflow-y-auto"> {#each (ctx?.tasks ?? []) as task (task.id)} <div style={`height: ${ctx?.rowHeight ?? 40}px`} class={cn( 'group/row text-foreground hover:bg-muted/40 flex cursor-pointer items-center justify-between px-3 text-xs transition-colors', task.isGroup && 'bg-muted/10 font-semibold', )} onclick={() => ctx?.onTaskClick(task)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() ctx?.onTaskClick(task) } }} role="button" tabindex="0" > <!-- Task Name & Chevron / Indent --> <div class="flex min-w-0 flex-1 items-center gap-1.5 pr-2"> {#if task.isGroup || (task.children && task.children.length > 0)} <button type="button" class="text-muted-foreground hover:text-foreground flex size-4 shrink-0 items-center justify-center rounded-xs p-0.5" onclick={(e) => { e.stopPropagation() ctx?.toggleExpand?.(task.id) }} > {#if task.isExpanded !== false} <ChevronDown class="size-3" /> {:else} <ChevronRight class="size-3" /> {/if} </button> {:else if task.parentId} <span class="w-4 shrink-0"></span> {/if} <!-- Status Indicator Dot --> {#if task.status} <span class={cn( 'size-2 shrink-0 rounded-full', task.status === 'done' && 'bg-emerald-500 ring-2 ring-emerald-500/20', task.status === 'in-progress' && 'bg-primary ring-primary/20 ring-2', task.status === 'at-risk' && 'bg-amber-500 ring-2 ring-amber-500/20', task.status === 'todo' && 'bg-muted-foreground/40', task.status === 'blocked' && 'bg-destructive ring-destructive/20 ring-2', )} ></span> {/if} <span class="truncate font-medium">{task.name}</span> </div> <!-- Priority Flag --> {#if showPriority} <div class="flex w-12 shrink-0 items-center justify-center"> {#if task.priority} <Flag class={cn('size-3', priorityColors[task.priority])} /> {/if} </div> {/if} <!-- Duration / Due Date Tag --> <div class="text-muted-foreground w-16 shrink-0 text-right font-mono text-xs"> {#if task.isMilestone} <span class="text-xs font-semibold text-amber-500"> Milestone </span> {:else} {calculateDays(task.startDate, task.endDate)} {/if} </div> </div> {/each} </div> </div>src/lib/components/ui/gantt/GanttTimeline.svelte7.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { GanttTask } from './types' export interface GanttTimelineProps extends HTMLAttributes<HTMLDivElement> { showTodayLine?: boolean showDependencies?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import GanttBar from './GanttBar.svelte' import GanttMilestone from './GanttMilestone.svelte' import { getGanttContext } from './context' let { class: className, showTodayLine = true, showDependencies = true, ref = $bindable(null), ...restProps }: GanttTimelineProps = $props() const ctx = getGanttContext() const timelineTasks = $derived<GanttTask[]>(ctx?.tasks ?? []) // Generate header date columns const columns = $derived.by(() => { if (!ctx) return [] const list: { date: Date; label: string; subLabel: string; isWeekend: boolean }[] = [] const start = new Date(ctx.startDate) const total = ctx.totalDays for (let i = 0; i < total; i++) { const d = new Date(start) d.setDate(d.getDate() + i) const dayOfWeek = d.getDay() const isWeekend = dayOfWeek === 0 || dayOfWeek === 6 list.push({ date: d, label: d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), subLabel: d.toLocaleDateString(undefined, { weekday: 'narrow' }), isWeekend, }) } return list }) const timelineWidth = $derived.by(() => { if (!ctx) return 800 return columns.length * ctx.columnWidth }) function getTaskCoordinates(task: GanttTask, index: number) { if (!ctx) return { left: 0, width: 100, top: 0, height: 28 } const start = new Date(ctx.startDate).getTime() const taskStart = new Date(task.startDate).getTime() const taskEnd = new Date(task.endDate).getTime() const oneDay = 1000 * 60 * 60 * 24 const startDiffDays = Math.max(0, (taskStart - start) / oneDay) const durationDays = Math.max(1, (taskEnd - taskStart) / oneDay) const left = startDiffDays * ctx.columnWidth const width = durationDays * ctx.columnWidth const rowHeight = ctx.rowHeight const top = index * rowHeight + (rowHeight - 28) / 2 return { left, width, top, height: 28 } } const todayPosition = $derived.by(() => { if (!ctx) return null const start = new Date(ctx.startDate).getTime() const today = new Date().setHours(0, 0, 0, 0) const oneDay = 1000 * 60 * 60 * 24 const diffDays = (today - start) / oneDay if (diffDays < 0 || diffDays > ctx.totalDays) return null return diffDays * ctx.columnWidth + ctx.columnWidth / 2 }) // Compute SVG Dependency curves between tasks const dependencyPaths = $derived.by(() => { if (!ctx || !showDependencies) return [] const tasksList: GanttTask[] = ctx.tasks ?? [] const taskMap = new Map<string, { task: GanttTask; index: number }>() tasksList.forEach((t, i) => taskMap.set(t.id, { task: t, index: i })) const paths: { d: string; fromId: string; toId: string }[] = [] tasksList.forEach((toTask, toIdx) => { if (!toTask.dependencies || toTask.dependencies.length === 0) return toTask.dependencies.forEach((fromId) => { const fromEntry = taskMap.get(fromId) if (!fromEntry) return const fromCoords = getTaskCoordinates(fromEntry.task, fromEntry.index) const toCoords = getTaskCoordinates(toTask, toIdx) const startX = fromEntry.task.isMilestone ? fromCoords.left : fromCoords.left + fromCoords.width const startY = fromCoords.top + 14 const endX = toCoords.left const endY = toCoords.top + 14 const deltaX = Math.max(16, (endX - startX) / 2) const d = `M ${startX} ${startY} C ${startX + deltaX} ${startY}, ${endX - deltaX} ${endY}, ${endX} ${endY}` paths.push({ d, fromId, toId: toTask.id }) }) }) return paths }) </script> <div bind:this={ref} data-uipkge data-slot="gantt-timeline" class={cn('bg-background relative flex-1 overflow-x-auto overflow-y-hidden select-none', className)} {...restProps} > <div style={`width: ${timelineWidth}px`} class="relative"> <!-- Header Dates --> <div style={`height: ${ctx?.headerHeight ?? 48}px`} class="border-border bg-muted/10 sticky top-0 z-20 flex border-b" > {#each columns as col, i (i)} <div style={`width: ${ctx?.columnWidth ?? 44}px`} class={cn( 'border-border/50 text-muted-foreground flex flex-col items-center justify-center border-r text-[10px]', col.isWeekend && 'bg-muted/20 text-muted-foreground/60', )} > <span class="text-foreground font-medium">{col.label}</span> <span class="text-[9px]">{col.subLabel}</span> </div> {/each} </div> <!-- Grid Background & Task Bars --> <div class="relative"> <!-- Column grid lines --> <div class="pointer-events-none absolute inset-0 flex"> {#each columns as col, i (i)} <div style={`width: ${ctx?.columnWidth ?? 44}px`} class={cn('border-border/30 h-full border-r', col.isWeekend && 'bg-muted/15')} ></div> {/each} </div> <!-- Today Marker Line --> {#if showTodayLine && todayPosition != null} <div style={`left: ${todayPosition}px`} class="pointer-events-none absolute inset-y-0 z-30 flex flex-col items-center" > <div class="bg-destructive text-destructive-foreground rounded-full px-1.5 py-0.5 text-[9px] font-bold shadow-xs"> Today </div> <div class="bg-destructive/60 h-full w-[1.5px] border-r border-dashed"></div> </div> {/if} <!-- SVG Dependencies Overlay --> {#if dependencyPaths.length > 0} <svg width={timelineWidth} height={(ctx?.tasks.length ?? 0) * (ctx?.rowHeight ?? 40)} class="pointer-events-none absolute inset-0 z-10" > <defs> <marker id="gantt-arrow" viewBox="0 0 6 6" refX="5" refY="3" markerWidth="6" markerHeight="6" orient="auto"> <path d="M 0 0 L 6 3 L 0 6 z" class="fill-primary/60" /> </marker> </defs> {#each dependencyPaths as p, i (i)} <path d={p.d} fill="none" class="stroke-primary/50" stroke-width="1.5" stroke-dasharray="3,3" marker-end="url(#gantt-arrow)" /> {/each} </svg> {/if} <!-- Task Rows & Bars --> {#each timelineTasks as task, idx (task.id)} {@const coords = getTaskCoordinates(task, idx)} {@const rowHeight = ctx?.rowHeight ?? 40} <div style={`height: ${rowHeight}px`} class="border-border/40 hover:bg-muted/10 relative border-b transition-colors"> {#if task.isMilestone} <GanttMilestone {task} left={coords.left} top={rowHeight / 2} onclick={(t) => ctx?.onTaskClick(t)} /> {:else} <GanttBar {task} left={coords.left} width={coords.width} top={(coords.height - 28) / 2 + 6} height={28} onclick={(t) => ctx?.onTaskClick(t)} /> {/if} </div> {/each} </div> </div> </div>src/lib/components/ui/gantt/GanttBar.svelte4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { GanttTask } from './types' // `onclick` carries the clicked task (not the MouseEvent), so it replaces // the DOM handler from HTMLAttributes. export interface GanttBarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onclick'> { task: GanttTask left: number width: number top: number height: number onclick?: (task: GanttTask) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, task, left, width, top, height, onclick, ref = $bindable(null), ...restProps }: GanttBarProps = $props() const statusColors: Record<string, string> = { done: 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 border-emerald-500/40', 'in-progress': 'bg-primary/20 text-primary border-primary/40', 'at-risk': 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/40', todo: 'bg-muted/80 text-muted-foreground border-border', blocked: 'bg-destructive/20 text-destructive border-destructive/40', } const progressColors: Record<string, string> = { done: 'bg-emerald-500/40', 'in-progress': 'bg-primary/40', 'at-risk': 'bg-amber-500/40', todo: 'bg-muted-foreground/20', blocked: 'bg-destructive/40', } function handleActivate() { onclick?.(task) } </script> {#if task.isGroup} <!-- Group Parent Task Bracket Bar --> <div bind:this={ref} data-uipkge data-slot="gantt-group-bar" role="button" tabindex="0" style={`left: ${left}px; width: ${Math.max(24, width)}px; top: ${top + 4}px; height: ${height - 8}px`} class={cn( 'group/bar bg-foreground/80 text-background hover:bg-foreground absolute z-10 flex cursor-pointer items-center justify-between rounded-xs px-2 text-xs font-semibold shadow-xs select-none', className, )} onclick={handleActivate} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleActivate() } }} {...restProps} > <span class="truncate">{task.name}</span> {#if task.progress != null} <span class="font-mono text-xs opacity-80"> {task.progress}% </span> {/if} </div> {:else} <!-- Standard Deliverable Bar --> <div bind:this={ref} data-uipkge data-slot="gantt-bar" role="button" tabindex="0" style={`left: ${left}px; width: ${Math.max(24, width)}px; top: ${top}px; height: ${height}px`} class={cn( 'group/bar absolute z-10 flex cursor-pointer items-center overflow-hidden rounded-md border text-xs font-medium shadow-xs transition-[box-shadow,transform] select-none hover:scale-[1.01] hover:shadow-md', task.color ? task.color : statusColors[task.status ?? 'in-progress'], className, )} onclick={handleActivate} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleActivate() } }} {...restProps} > <!-- Progress fill --> {#if task.progress != null && task.progress > 0} <div style={`width: ${task.progress}%`} class={cn('absolute inset-y-0 left-0 transition-[width]', progressColors[task.status ?? 'in-progress'])} ></div> {/if} <!-- Content --> <div class="relative z-10 flex w-full min-w-0 items-center justify-between px-2"> <span class="truncate font-medium">{task.name}</span> {#if task.progress != null} <span class="ml-1 shrink-0 font-mono text-xs opacity-80"> {task.progress}% </span> {/if} </div> <!-- Left / Right Resize Handles --> <div aria-hidden="true" class="bg-foreground/20 absolute inset-y-0 left-0 w-1.5 cursor-ew-resize opacity-0 transition-opacity group-hover/bar:opacity-100" ></div> <div aria-hidden="true" class="bg-foreground/20 absolute inset-y-0 right-0 w-1.5 cursor-ew-resize opacity-0 transition-opacity group-hover/bar:opacity-100" ></div> </div> {/if}src/lib/components/ui/gantt/GanttMilestone.svelte1.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { GanttTask } from './types' // `onclick` carries the clicked task (not the MouseEvent), so it replaces // the DOM handler from HTMLAttributes. export interface GanttMilestoneProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onclick'> { task: GanttTask left: number top: number size?: number onclick?: (task: GanttTask) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, task, left, top, size = 16, onclick, ref = $bindable(null), ...restProps }: GanttMilestoneProps = $props() function handleActivate() { onclick?.(task) } </script> <div bind:this={ref} data-uipkge data-slot="gantt-milestone" role="button" tabindex="0" style={`left: ${left - size / 2}px; top: ${top - size / 2}px; width: ${size}px; height: ${size}px`} class={cn( 'border-primary bg-primary absolute z-20 rotate-45 cursor-pointer rounded-xs border-2 shadow-sm transition-transform hover:scale-125', className, )} title={`${task.name} (${task.startDate})`} onclick={handleActivate} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleActivate() } }} {...restProps} ></div>src/lib/components/ui/gantt/GanttContextMenu.svelte5.3 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { GanttTask, GanttTaskPriority, GanttTaskStatus } from './types' export interface GanttContextMenuProps { task: GanttTask children?: Snippet onedit?: (task: GanttTask) => void onstatuschange?: (task: GanttTask, status: GanttTaskStatus) => void onprioritychange?: (task: GanttTask, priority: GanttTaskPriority) => void onduplicate?: (task: GanttTask) => void ondelete?: (task: GanttTask) => void } </script> <script lang="ts"> import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from '$lib/components/ui/context-menu' import { Clock, Copy, Edit2, Flag, Layers, Trash2 } from '@lucide/svelte' let { task, onedit, onstatuschange, onprioritychange, onduplicate, ondelete, children, }: GanttContextMenuProps = $props() function copyTaskId() { if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(task.id) } } </script> <ContextMenu> <!-- The Vue twin uses as-child here; without a render-prop contract on the Svelte Trigger yet, nest the trigger content in the default element. --> <ContextMenuTrigger> {@render children?.()} </ContextMenuTrigger> <ContextMenuContent class="w-56"> <ContextMenuLabel class="flex items-center justify-between text-xs"> <span class="truncate font-semibold">{task.name}</span> <span class="text-muted-foreground font-mono text-xs">{task.id}</span> </ContextMenuLabel> <ContextMenuSeparator /> <!-- Edit / Inspect --> <ContextMenuItem onSelect={() => onedit?.(task)}> <Edit2 class="mr-2 size-3.5" /> <span>View Details</span> <ContextMenuShortcut>↵</ContextMenuShortcut> </ContextMenuItem> <!-- Status Submenu --> <ContextMenuSub> <ContextMenuSubTrigger> <Clock class="text-primary mr-2 size-3.5" /> <span>Change Status</span> </ContextMenuSubTrigger> <ContextMenuSubContent class="w-44"> <ContextMenuRadioGroup value={task.status ?? 'todo'} onValueChange={(v) => onstatuschange?.(task, v as GanttTaskStatus)} > <ContextMenuRadioItem value="done"> <span class="mr-2 size-2 rounded-full bg-emerald-500"></span> <span>Completed</span> </ContextMenuRadioItem> <ContextMenuRadioItem value="in-progress"> <span class="bg-primary mr-2 size-2 rounded-full"></span> <span>In Progress</span> </ContextMenuRadioItem> <ContextMenuRadioItem value="at-risk"> <span class="mr-2 size-2 rounded-full bg-amber-500"></span> <span>At Risk</span> </ContextMenuRadioItem> <ContextMenuRadioItem value="blocked"> <span class="bg-destructive mr-2 size-2 rounded-full"></span> <span>Blocked</span> </ContextMenuRadioItem> <ContextMenuRadioItem value="todo"> <span class="bg-muted-foreground/40 mr-2 size-2 rounded-full"></span> <span>To Do</span> </ContextMenuRadioItem> </ContextMenuRadioGroup> </ContextMenuSubContent> </ContextMenuSub> <!-- Priority Submenu --> <ContextMenuSub> <ContextMenuSubTrigger> <Flag class="mr-2 size-3.5 text-amber-500" /> <span>Set Priority</span> </ContextMenuSubTrigger> <ContextMenuSubContent class="w-40"> <ContextMenuRadioGroup value={task.priority ?? 'medium'} onValueChange={(v) => onprioritychange?.(task, v as GanttTaskPriority)} > <ContextMenuRadioItem value="urgent"> <Flag class="text-destructive mr-2 size-3" /> <span>Urgent</span> </ContextMenuRadioItem> <ContextMenuRadioItem value="high"> <Flag class="mr-2 size-3 text-amber-500" /> <span>High</span> </ContextMenuRadioItem> <ContextMenuRadioItem value="medium"> <Flag class="text-primary mr-2 size-3" /> <span>Medium</span> </ContextMenuRadioItem> <ContextMenuRadioItem value="low"> <Flag class="text-muted-foreground mr-2 size-3" /> <span>Low</span> </ContextMenuRadioItem> </ContextMenuRadioGroup> </ContextMenuSubContent> </ContextMenuSub> <ContextMenuSeparator /> <!-- Copy ID --> <ContextMenuItem onSelect={copyTaskId}> <Copy class="mr-2 size-3.5" /> <span>Copy Task ID</span> <ContextMenuShortcut>⌘C</ContextMenuShortcut> </ContextMenuItem> <!-- Duplicate --> <ContextMenuItem onSelect={() => onduplicate?.(task)}> <Layers class="mr-2 size-3.5" /> <span>Duplicate</span> <ContextMenuShortcut>⌘D</ContextMenuShortcut> </ContextMenuItem> <ContextMenuSeparator /> <!-- Delete --> <ContextMenuItem class="text-destructive focus:text-destructive" onSelect={() => ondelete?.(task)}> <Trash2 class="mr-2 size-3.5" /> <span>Delete Deliverable</span> <ContextMenuShortcut>⌫</ContextMenuShortcut> </ContextMenuItem> </ContextMenuContent> </ContextMenu>src/lib/components/ui/gantt/context.ts1.2 kB
import { getContext } from 'svelte' import type { GanttScale, GanttTask } from './types' export const GANTT_CONTEXT_KEY = Symbol('gantt') export interface GanttContext { scale: GanttScale /** React-parity setter (the Svelte-idiomatic path is assigning `scale`). */ setScale: (scale: GanttScale) => void startDate: Date endDate: Date totalDays: number columnWidth: number rowHeight: number headerHeight: number treeWidth: number tasks: GanttTask[] onTaskClick: (task: GanttTask) => void onTaskChange: (task: GanttTask) => void toggleExpand?: (id: string) => void } /** React's context-value name, kept as an alias (shapes match: Svelte's * `onTaskClick`/`onTaskChange` are required because `<Gantt>` always * provides them; React's are optional). */ export type GanttContextValue = GanttContext export function getGanttContext(): GanttContext | undefined { return getContext<GanttContext | undefined>(GANTT_CONTEXT_KEY) } /** React's `useGantt` name — like React, throws outside `<Gantt>`. * Prefer `getGanttContext()` when a missing provider is not an error. */ export function useGantt(): GanttContext { const ctx = getGanttContext() if (!ctx) throw new Error('useGantt must be used within a <Gantt /> component') return ctx }src/lib/components/ui/gantt/types.ts0.9 kB
export type GanttScale = 'day' | 'week' | 'month' | 'quarter' | 'year' export type GanttTaskStatus = 'todo' | 'in-progress' | 'done' | 'blocked' | 'at-risk' export type GanttTaskPriority = 'low' | 'medium' | 'high' | 'urgent' export interface GanttDependency { fromId: string toId: string type?: 'finish-to-start' | 'start-to-start' | 'finish-to-finish' } export interface GanttAssignee { name: string avatar?: string initials?: string role?: string } export interface GanttTask { id: string name: string startDate: string // YYYY-MM-DD endDate: string // YYYY-MM-DD progress?: number // 0 to 100 color?: string status?: GanttTaskStatus priority?: GanttTaskPriority assignee?: GanttAssignee isMilestone?: boolean isGroup?: boolean parentId?: string | null isExpanded?: boolean dependencies?: string[] // task IDs children?: GanttTask[] } export interface GanttColumn { key: string label: string width?: string }src/lib/components/ui/gantt/index.ts0.7 kB
export { default as Gantt, type GanttProps } from './Gantt.svelte' export { default as GanttHeader, type GanttHeaderProps } from './GanttHeader.svelte' export { default as GanttTree, type GanttTreeProps } from './GanttTree.svelte' export { default as GanttTimeline, type GanttTimelineProps } from './GanttTimeline.svelte' export { default as GanttBar, type GanttBarProps } from './GanttBar.svelte' export { default as GanttMilestone, type GanttMilestoneProps } from './GanttMilestone.svelte' export { default as GanttContextMenu, type GanttContextMenuProps } from './GanttContextMenu.svelte' export { GANTT_CONTEXT_KEY, getGanttContext, useGantt, type GanttContext, type GanttContextValue } from './context' export * from './types'
Raw manifest:https://uipkge.dev/r/svelte/gantt.json