"use client"
import * as React from "react"
import {
ArrowsInLineHorizontalIcon,
CalendarBlankIcon,
CaretDownIcon,
CaretRightIcon,
CheckCircleIcon,
DotsThreeIcon,
FlagIcon,
FunnelSimpleIcon,
KanbanIcon,
ListBulletsIcon,
MagnifyingGlassIcon,
MinusIcon,
PlusIcon,
RocketIcon,
RowsIcon,
TextAlignLeftIcon,
TreeStructureIcon,
UserIcon,
WarningIcon,
} from "@phosphor-icons/react"
import { cn } from "cn"
import { MenuChoice, MenuChoices, tint, ToolbarButton, uid } from "@/components/blocks/airtable"
import {
AddTaskForm,
AssigneePicker,
dayDiff,
DuePicker,
dueLabel,
Flag,
ME,
People,
PRIORITIES,
priority,
PriorityMenu,
seedTasks,
status,
STATUSES,
StatusIcon,
StatusMenu,
Tag,
TAGS,
TaskPanel,
type PriorityKey,
type StatusKey,
type Task,
} from "@/components/blocks/clickup-list"
import { FavoriteButton } from "@/components/ui/favorite-button"
import {
Kanban,
KanbanBoard,
KanbanColumn,
KanbanColumnContent,
KanbanColumnHeader,
KanbanItem,
KanbanOverlay,
type KanbanDragEnd,
type KanbanValue,
} from "@/components/ui/kanban"
import { ScrollFadeArea } from "@/components/ui/scroll-fade-area"
import { cue } from "@/lib/sounds"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
import { Card, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Item } from "@/components/ui/item"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Toggle } from "@/components/ui/toggle"
/* Board model */
type Lanes = "none" | "priority"
type Items = KanbanValue<string>
// A status column, tinted by its status colour.
const COLUMN =
"rounded-[calc(var(--ui-radius)+0.5rem)] bg-[color-mix(in_oklab,var(--tint)_9%,var(--card))] p-2 data-over:ring-sky-500/60"
// A container is one column in one swimlane.
const containerId = (lane: string, s: StatusKey) => `col:${lane}::${s}`
const parseContainer = (id: string) => {
const [lane, s] = id.slice(4).split("::")
return { lane, status: s as StatusKey }
}
// A couple of cards carry a cover, like images on ClickUp cards.
const COVERS: { [id: string]: string } = {
t11: "linear-gradient(135deg, oklch(80% 0.1 330), oklch(78% 0.11 230))",
t9: "linear-gradient(135deg, oklch(35% 0.02 260), oklch(80% 0.01 260))",
}
/* Cards */
type CardProps = {
task: Task
subtasks: Task[]
open: boolean
onToggleSubtasks: () => void
onOpen: () => void
update: (id: string, patch: Partial<Task>) => void
overlay?: boolean
}
function TaskCard({ task, subtasks, open, onToggleSubtasks, onOpen, update, overlay }: CardProps) {
const overdue = task.due && task.status !== "done" && dayDiff(task.due) < 0
const s = status(task.status)
const done = subtasks.filter((t) => t.status === "done").length
// Chips inside the card keep their own clicks, so they do not open the panel.
const stop = (e: React.SyntheticEvent) => e.stopPropagation()
return (
<Card
size="sm"
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => e.key === "Enter" && e.target === e.currentTarget && onOpen()}
className={cn(
"cursor-grab gap-2.5 px-3 text-[13px] select-none active:cursor-grabbing",
COVERS[task.id] && "pt-0",
overlay && "rotate-2 shadow-xl ring-2 ring-sky-500/40"
)}
>
{COVERS[task.id] && <div className="-mx-3 h-24" style={{ background: COVERS[task.id] }} />}
<div className="flex items-start gap-1.5">
<CardTitle className={cn("flex-1 text-[13px] leading-snug font-medium", task.status === "done" && "text-muted-foreground")}>
{task.name}
</CardTitle>
{task.description && <TextAlignLeftIcon weight="bold" className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />}
</div>
<div className="flex flex-wrap items-center gap-1" onClick={stop} onKeyDown={stop}>
<StatusMenu value={task.status} onChange={(v) => update(task.id, { status: v })}>
<Button type="button" variant="ghost" size="inline" aria-label={`Status: ${s.label}`}>
<Badge className="h-5 gap-1 rounded-md border-0 px-1.5 text-white" style={{ background: s.color }}>
<s.icon weight={task.status === "done" ? "bold" : "duotone"} className="size-3" />
{s.label}
</Badge>
</Button>
</StatusMenu>
<AssigneePicker value={task.assignees} onChange={(v) => update(task.id, { assignees: v })}>
<Button type="button" variant="ghost" size="inline" aria-label="Assignees">
<People names={task.assignees} max={3} />
</Button>
</AssigneePicker>
<DuePicker value={task.due} onChange={(v) => update(task.id, { due: v })}>
<Button
type="button"
variant="outline"
size="xs"
aria-label="Due date"
className={cn("h-6 font-normal", overdue && "text-red-600 dark:text-red-400")}
>
<CalendarBlankIcon weight="duotone" />
{task.due && dueLabel(task.due)}
</Button>
</DuePicker>
<PriorityMenu value={task.priority} onChange={(v) => update(task.id, { priority: v })}>
<Button type="button" variant="outline" size="xs" aria-label="Priority" className="h-6 font-normal">
<Flag value={task.priority} label={task.priority !== "none"} />
</Button>
</PriorityMenu>
{task.tags.map((t) => (
<Tag key={t} value={t} />
))}
</div>
{subtasks.length > 0 && (
<Collapsible open={open} onOpenChange={onToggleSubtasks} className="flex flex-col gap-1" onClick={stop} onKeyDown={stop}>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="inline" className="w-fit text-xs font-normal text-muted-foreground">
<TreeStructureIcon weight="duotone" />
{done}/{subtasks.length} subtasks
{open ? <CaretDownIcon weight="bold" className="size-3" /> : <CaretRightIcon weight="bold" className="size-3" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="t-swap-in flex flex-col gap-1 pl-1">
{subtasks.map((st) => (
<label key={st.id} className="flex items-center gap-2 text-xs">
<Checkbox
checked={st.status === "done"}
onCheckedChange={(on) => update(st.id, { status: on ? "done" : "todo" })}
/>
<span className={cn("truncate", st.status === "done" && "text-muted-foreground line-through")}>
{st.name}
</span>
</label>
))}
</CollapsibleContent>
</Collapsible>
)}
</Card>
)
}
/* Columns */
function WipLimit({
count,
limit,
onChange,
}: {
count: number
limit: number | undefined
onChange: (limit: number | undefined) => void
}) {
const over = limit !== undefined && count >= limit
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="inline"
aria-label="Count and limit"
className={cn("text-xs tabular-nums", over ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground")}
>
{over && <WarningIcon weight="duotone" className="size-3.5" />}
{limit !== undefined ? `${count}/${limit}` : count}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="flex w-64 flex-col gap-3 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm">Set limit to</span>
<ButtonGroup>
<Button type="button" variant="outline" size="icon-sm" aria-label="Lower limit" onClick={() => onChange(Math.max(1, (limit ?? count) - 1))}>
<MinusIcon weight="bold" />
</Button>
<ButtonGroupText className="w-10 justify-center tabular-nums">{limit ?? "-"}</ButtonGroupText>
<Button type="button" variant="outline" size="icon-sm" aria-label="Raise limit" onClick={() => onChange((limit ?? count) + 1)}>
<PlusIcon weight="bold" />
</Button>
</ButtonGroup>
</div>
{over && (
<Alert className="border-amber-500/30 bg-amber-500/10 text-amber-900 dark:text-amber-100">
<WarningIcon weight="duotone" />
<AlertDescription className="text-inherit">
{count > limit! ? "This column is over its limit." : "You've reached the limit."}
</AlertDescription>
</Alert>
)}
{limit !== undefined && (
<Button type="button" variant="ghost" size="sm" onClick={() => onChange(undefined)}>
Clear limit
</Button>
)}
</PopoverContent>
</Popover>
)
}
/* The board */
export type ClickupKanbanProps = {
/** List name in the header. */
name?: string
/** Saves tasks and board settings to localStorage under this key. */
storageKey?: string
className?: string
}
type Saved = { tasks: Task[]; lanes: Lanes; limits: { [s: string]: number }; collapsed: StatusKey[] }
export function ClickupKanban({ name = "Marketing", storageKey, className }: ClickupKanbanProps) {
const [tasks, setTasks] = React.useState<Task[]>(seedTasks)
const [lanes, setLanes] = React.useState<Lanes>("none")
const [limits, setLimits] = React.useState<{ [s: string]: number }>({ progress: 4 })
const [collapsed, setCollapsed] = React.useState<StatusKey[]>([])
const [ready, setReady] = React.useState(!storageKey)
const [query, setQuery] = React.useState("")
const [meMode, setMeMode] = React.useState(false)
const [showClosed, setShowClosed] = React.useState(true)
const [priorityFilter, setPriorityFilter] = React.useState<PriorityKey[]>([])
const [tagFilter, setTagFilter] = React.useState<string[]>([])
const [openSubtasks, setOpenSubtasks] = React.useState<string[]>([])
const [closedLanes, setClosedLanes] = React.useState<string[]>([])
const [open, setOpen] = React.useState<string | null>(null)
// The board while a card is dragged. It goes back to null on drop.
const [dragItems, setDragItems] = React.useState<Items | null>(null)
React.useEffect(() => {
if (!storageKey) return
try {
const saved = JSON.parse(window.localStorage.getItem(storageKey) ?? "null") as Saved | null
if (saved?.tasks?.length) {
setTasks(saved.tasks)
setLanes(saved.lanes)
setLimits(saved.limits)
setCollapsed(saved.collapsed)
}
} catch {
// A broken save falls back to the sample board.
}
setReady(true)
}, [storageKey])
React.useEffect(() => {
if (!ready || !storageKey) return
try {
window.localStorage.setItem(storageKey, JSON.stringify({ tasks, lanes, limits, collapsed } satisfies Saved))
} catch {
// Storage can be full or blocked.
}
}, [tasks, lanes, limits, collapsed, ready, storageKey])
const update = (id: string, patch: Partial<Task>) => {
const t = tasks.find((x) => x.id === id)
if (patch.status === "done" && t && t.status !== "done") cue("success", { emphasis: "subtle", force: true })
setTasks((ts) => ts.map((x) => (x.id === id ? { ...x, ...patch } : x)))
}
const remove = (ids: string[]) => {
cue("close", { emphasis: "strong", force: true })
setTasks((ts) => ts.filter((t) => !ids.includes(t.id) && !ids.includes(t.parent ?? "")))
}
const addTask = (taskName: string, patch: Partial<Task> = {}) => {
const t: Task = {
id: `t-${uid()}`,
name: taskName,
status: "todo",
assignees: meMode ? [ME] : [],
due: null,
priority: "none",
tags: [],
budget: null,
description: "",
parent: null,
...patch,
}
setTasks((ts) => [...ts, t])
return t.id
}
const statuses = STATUSES.filter((s) => showClosed || s.key !== "done")
const laneKeys = lanes === "priority" ? PRIORITIES.map((p) => p.key as string) : ["all"]
const laneOf = (t: Task) => (lanes === "priority" ? t.priority : "all")
const childrenOf = (id: string) => tasks.filter((t) => t.parent === id)
const q = query.trim().toLowerCase()
const visible = (t: Task) =>
!t.parent &&
(showClosed || t.status !== "done") &&
(!meMode || t.assignees.includes(ME)) &&
(!priorityFilter.length || priorityFilter.includes(t.priority)) &&
(!tagFilter.length || t.tags.some((x) => tagFilter.includes(x))) &&
(!q || t.name.toLowerCase().includes(q))
const baseItems = React.useMemo(() => {
const items: Items = {}
for (const lane of laneKeys) for (const s of statuses) items[containerId(lane, s.key)] = []
for (const t of tasks) if (visible(t)) items[containerId(laneOf(t), t.status)]?.push(t.id)
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tasks, lanes, showClosed, meMode, priorityFilter, tagFilter, q])
const items = dragItems ?? baseItems
const byId = new Map(tasks.map((t) => [t.id, t]))
const countOf = (s: StatusKey) => laneKeys.reduce((n, lane) => n + (items[containerId(lane, s)]?.length ?? 0), 0)
/* Drag and drop. The Kanban primitive moves cards, the drop updates the tasks. */
const onDragEnd = ({ id, to, value: final }: KanbanDragEnd<string>) => {
setDragItems(null)
const { lane, status: s } = parseContainer(to)
const task = byId.get(id)!
const patch: Partial<Task> = { status: s }
if (lanes === "priority") patch.priority = lane as PriorityKey
const limit = limits[s]
const count = laneKeys.reduce((n, l) => n + (final[containerId(l, s)]?.length ?? 0), 0)
if (s !== task.status && limit !== undefined && count > limit) cue("warning", { force: true })
if (s === "done" && task.status !== "done") cue("success", { force: true })
// Rebuild the task order from the board, so the drop position sticks.
const order = Object.values(final).flat()
setTasks((ts) => {
const next = ts.map((t) => (t.id === id ? { ...t, ...patch } : t))
const rank = new Map(order.map((x, i) => [x, i]))
return [...next].sort((x, y) => (rank.get(x.id) ?? 1e6 + next.indexOf(x)) - (rank.get(y.id) ?? 1e6 + next.indexOf(y)))
})
}
const filterCount = priorityFilter.length + tagFilter.length
const toggleCollapse = (s: StatusKey) =>
setCollapsed((c) => (c.includes(s) ? c.filter((x) => x !== s) : [...c, s]))
// The status header for a column: pill, count and limit, menu and add.
const columnHeader = (s: (typeof STATUSES)[number], lane: string) => {
const isCollapsed = collapsed.includes(s.key)
const count = countOf(s.key)
if (isCollapsed)
return (
<Button
type="button"
variant="ghost"
aria-label={`Expand ${s.label}`}
onClick={() => toggleCollapse(s.key)}
className="t-swap-in h-auto w-full flex-col justify-start gap-3 px-0 py-2"
>
<Badge
className="h-auto rounded-md border-0 px-0.5 py-2 text-xs font-semibold tracking-wide text-white uppercase [writing-mode:vertical-rl]"
style={{ background: s.color }}
>
{s.label}
</Badge>
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
</Button>
)
return (
<div className="flex items-center gap-1.5 px-1">
<Badge className="h-6 gap-1.5 rounded-md border-0 px-2 text-xs font-semibold tracking-wide text-white uppercase" style={{ background: s.color }}>
<s.icon weight={s.key === "done" ? "bold" : "duotone"} className="size-3.5" />
{s.label}
</Badge>
<WipLimit
count={count}
limit={limits[s.key]}
onChange={(v) =>
setLimits((l) => {
const next = { ...l }
if (v === undefined) delete next[s.key]
else next[s.key] = v
return next
})
}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon-xs" aria-label={`${s.label} options`} className="ml-auto">
<DotsThreeIcon weight="bold" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => toggleCollapse(s.key)}>
<ArrowsInLineHorizontalIcon weight="bold" />
Collapse column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Add task to ${s.label}`}
onClick={() => setOpen(addTask("Untitled task", { status: s.key, ...(lane !== "all" ? { priority: lane as PriorityKey } : {}) }))}
>
<PlusIcon weight="bold" />
</Button>
</div>
)
}
const columnBody = (s: (typeof STATUSES)[number], lane: string) => {
const id = containerId(lane, s.key)
const ids = items[id] ?? []
if (collapsed.includes(s.key)) return null
return (
<>
<KanbanColumnContent>
{ids.map((tid) => {
const t = byId.get(tid)
if (!t) return null
return (
<KanbanItem key={tid} value={tid} asHandle>
<TaskCard
task={t}
subtasks={childrenOf(tid)}
open={openSubtasks.includes(tid)}
onToggleSubtasks={() =>
setOpenSubtasks((o) => (o.includes(tid) ? o.filter((x) => x !== tid) : [...o, tid]))
}
onOpen={() => setOpen(tid)}
update={update}
/>
</KanbanItem>
)
})}
</KanbanColumnContent>
<AddTaskForm
inset={false}
onAdd={(n) => addTask(n, { status: s.key, ...(lane !== "all" ? { priority: lane as PriorityKey } : {}) })}
/>
</>
)
}
return (
<Card className={cn("relative w-full gap-0 py-0 text-sm", className)}>
{/* Header: list name and views */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 pt-3 pb-2">
<div className="flex items-center gap-2">
<span className="flex size-7 items-center justify-center rounded-md bg-rose-500 text-white">
<RocketIcon weight="duotone" className="size-4" />
</span>
<span className="text-base font-semibold">{name}</span>
<FavoriteButton kind="star" label="Star list" size="sm" />
</div>
<Tabs defaultValue="board">
<TabsList>
<TabsTrigger value="list" disabled>
<ListBulletsIcon weight="bold" />
List
</TabsTrigger>
<TabsTrigger value="board">
<KanbanIcon weight="duotone" />
Board
</TabsTrigger>
<TabsTrigger value="calendar" disabled>
<CalendarBlankIcon weight="duotone" />
Calendar
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Every board control sits in this one row */}
<div className="flex items-center gap-1 overflow-x-auto border-b px-2 py-1.5 [scrollbar-width:none]">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton
icon={RowsIcon}
label={lanes === "priority" ? "Status / Priority" : "Status"}
active={lanes !== "none"}
tint="bg-violet-500/15 text-violet-900 hover:bg-violet-500/20 dark:text-violet-100"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Swimlanes</DropdownMenuLabel>
<MenuChoices value={lanes} onValueChange={(v) => setLanes(v as Lanes)}>
<MenuChoice value="none">None</MenuChoice>
<MenuChoice value="priority">Priority</MenuChoice>
</MenuChoices>
</DropdownMenuContent>
</DropdownMenu>
<Popover>
<PopoverTrigger asChild>
<ToolbarButton
icon={FunnelSimpleIcon}
weight="bold"
label={filterCount ? `${filterCount} filters` : "Filters"}
active={filterCount > 0}
tint="bg-emerald-500/15 text-emerald-900 hover:bg-emerald-500/20 dark:text-emerald-100"
/>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<p className="px-2 py-1 text-xs font-medium text-muted-foreground">Priority</p>
{PRIORITIES.map((p) => (
<Item key={p.key} asChild size="xs" className="cursor-pointer flex-nowrap rounded-md border-0 px-2 py-1.5 hover:bg-muted">
<label>
<Checkbox
checked={priorityFilter.includes(p.key)}
onCheckedChange={(on) => setPriorityFilter((f) => (on ? [...f, p.key] : f.filter((x) => x !== p.key)))}
/>
<Flag value={p.key} />
{p.label}
</label>
</Item>
))}
<p className="mt-1 border-t px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground">Tags</p>
{TAGS.map((t) => (
<Item key={t} asChild size="xs" className="cursor-pointer flex-nowrap rounded-md border-0 px-2 py-1.5 hover:bg-muted">
<label>
<Checkbox checked={tagFilter.includes(t)} onCheckedChange={(on) => setTagFilter((f) => (on ? [...f, t] : f.filter((x) => x !== t)))} />
<Tag value={t} />
</label>
</Item>
))}
</PopoverContent>
</Popover>
<Toggle
size="sm"
aria-label="Me mode"
pressed={meMode}
onPressedChange={setMeMode}
className="shrink-0 font-normal data-[state=on]:bg-sky-500/15 data-[state=on]:text-sky-900 dark:data-[state=on]:text-sky-100"
>
<UserIcon weight="duotone" />
<span className="hidden sm:inline">Me mode</span>
</Toggle>
<Toggle
size="sm"
aria-label="Show closed"
pressed={showClosed}
onPressedChange={setShowClosed}
className="shrink-0 font-normal data-[state=on]:bg-sky-500/15 data-[state=on]:text-sky-900 dark:data-[state=on]:text-sky-100"
>
<CheckCircleIcon weight="bold" />
<span className="hidden sm:inline">Show closed</span>
</Toggle>
<div className="ml-auto flex shrink-0 items-center gap-1 pl-2">
<InputGroup className="w-36 lg:w-52">
<InputGroupAddon>
<MagnifyingGlassIcon weight="duotone" />
</InputGroupAddon>
<InputGroupInput placeholder="Search tasks" value={query} onChange={(e) => setQuery(e.target.value)} />
</InputGroup>
<Button type="button" size="sm" aria-label="Add task" onClick={() => setOpen(addTask("Untitled task"))}>
<PlusIcon weight="bold" />
<span className="hidden sm:inline">Task</span>
</Button>
</div>
</div>
{/* The board */}
<Kanban
value={items}
onValueChange={setDragItems}
onDragEnd={onDragEnd}
onDragCancel={() => setDragItems(null)}
>
<ScrollFadeArea fade="y" scrollbars="both" className="max-h-[44rem]">
<KanbanBoard className="block overflow-visible p-3">
{lanes === "none" ? (
<div className="flex items-start gap-3">
{statuses.map((s) => (
<KanbanColumn
key={s.key}
value={containerId("all", s.key)}
collapsed={collapsed.includes(s.key)}
style={tint(s.color)}
className={COLUMN}
>
{columnHeader(s, "all")}
{columnBody(s, "all")}
</KanbanColumn>
))}
</div>
) : (
<div className="flex w-max flex-col gap-3">
{/* Status headers once, then one row per priority */}
<div className="flex gap-3">
{statuses.map((s) => (
<KanbanColumnHeader
key={s.key}
data-collapsed={collapsed.includes(s.key) || undefined}
style={{ ...tint(s.color), transition: "width var(--resize-dur) var(--resize-ease)" }}
className={cn(COLUMN, "block shrink-0", collapsed.includes(s.key) ? "w-11" : "w-[min(17rem,82vw)]")}
>
{columnHeader(s, "all")}
</KanbanColumnHeader>
))}
</div>
{laneKeys.map((lane) => {
const p = priority(lane as PriorityKey)
const laneClosed = closedLanes.includes(lane)
const laneCount = statuses.reduce((n, s) => n + (items[containerId(lane, s.key)]?.length ?? 0), 0)
return (
<Collapsible
key={lane}
open={!laneClosed}
onOpenChange={() => setClosedLanes((c) => (c.includes(lane) ? c.filter((x) => x !== lane) : [...c, lane]))}
className="flex flex-col gap-2"
>
<div className="flex items-center gap-1.5">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={laneClosed ? `Expand ${p.label}` : `Collapse ${p.label}`}
>
{laneClosed ? <CaretRightIcon weight="bold" /> : <CaretDownIcon weight="bold" />}
</Button>
</CollapsibleTrigger>
<FlagIcon weight="duotone" className="size-4" style={{ color: p.color }} />
<span className="font-medium" style={{ color: lane === "none" ? undefined : p.color }}>
{p.label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">{laneCount}</span>
</div>
<CollapsibleContent className="t-swap-in flex items-start gap-3">
{statuses.map((s) => (
<KanbanColumn
key={s.key}
value={containerId(lane, s.key)}
collapsed={collapsed.includes(s.key)}
style={tint(s.color)}
className={COLUMN}
>
{columnBody(s, lane)}
</KanbanColumn>
))}
</CollapsibleContent>
</Collapsible>
)
})}
</div>
)}
</KanbanBoard>
</ScrollFadeArea>
<KanbanOverlay>
{({ id }) => {
const task = byId.get(id)
return task ? (
<div className="w-[min(16rem,78vw)]">
<TaskCard
task={task}
subtasks={childrenOf(task.id)}
open={false}
onToggleSubtasks={() => {}}
onOpen={() => {}}
update={() => {}}
overlay
/>
</div>
) : null
}}
</KanbanOverlay>
</Kanban>
<TaskPanel
tasks={tasks}
task={tasks.find((t) => t.id === open)}
listName={name}
update={update}
remove={remove}
addTask={addTask}
onClose={() => setOpen(null)}
/>
</Card>
)
}