HomeDocsComponentsBlocksChartsThemes
Introduction
  • Introduction
  • Installation
  • Themes
  • llms.txt
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Amount
  • Aspect Ratio
  • Audio Player
  • Avatar
  • Avatar Upload
  • Badge
  • Banner
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Code Block
  • Collapsible
  • Color Picker
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Countdown
  • Data Grid
  • Data Table
  • Date Picker
  • Date Range Picker
  • Description List
  • Device Mockup
  • Dialog
  • Direction
  • Dot Loader
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • File Upload
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kanban
  • Kbd
  • Label
  • Menubar
  • Meter
  • Multi Select
  • Native Select
  • Navigation Menu
  • Number Field
  • Pagination
  • Password Strength
  • Phone Input
  • Popover
  • Progress
  • Progress Circle
  • Radio Group
  • Rating
  • Resizable
  • Responsive Dialog
  • Rich Text Editor
  • Scroll Area
  • Segmented Control
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Sortable
  • Spinner
  • Stat
  • Stepper
  • Switch
  • Table
  • Tabs
  • Tag Input
  • Terminal
  • Textarea
  • Time Field
  • Timeline
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Tree
  • Typography
  • Video Player
  • Window Mockup
AI
  • Message Scroller
  • Message
  • Bubble
  • Marker
  • Attachment
  • Questionnaire
  • Composer
  • Reasoning
  • Tool Call
  • Approval Card
  • Citations
Blocks
  • Airtable
  • Airtable Kanban
  • ClickUp List
  • ClickUp Kanban
  • Repeatable Group
  • Command Palette
  • Sign In and Sign Up
  • Availability Scheduler
  • AI Chat
  • Action Bar
  • Notion
  • Banking
  • Lineup Builder
  • Sports Score
  • Strava
  • Sheets
  • Notices
  • Settings
  • Billing
Utilities
  • motion
  • sounds
  • scroll-fade
  • shimmer
Another UI. Built on shadcn/ui with Phosphor icons.

ClickUp List

PreviousNext

A ClickUp style list view with status groups, subtasks, inline editing, drag and drop and bulk actions, built from Another UI primitives.

Marketing
To do4
Content$250
-
PMM$150
$1,100
$1,500

Oct 17NormalContent

AP

Oct 21

TuesdayLowPMM

NK

High

MCLO
In progress3
Agent$2,500
1
PMM$1,800
PMM$300
Content$4,200
$8,800

MondayHighAgent

MC

TomorrowNormal1PMM

APMC

WednesdayLowPMM

AP

YesterdayUrgentContent

NK
Review2
Content$600
DesignContent$5,000
$5,600

TodayHighContent

MC

ThursdayNormalDesignContent

LO
Done3
Design$1,200
2
PMM$3,400
Design$900
Web$400
Design$800
$6,700

Oct 2LowDesign

LO

Oct 6Urgent2PMM

APNK

Oct 5NormalDesign

MC

Oct 6HighWeb

NK

Oct 8NormalDesign

LO

Installation#

pnpm dlx shadcn@latest add https://anotherui.dev/r/clickup-list.json

Features#

  • Status groups. Tasks sit in To do, In progress, Review and Done, each with a solid status pill, a count, its own column headers and a budget total. Collapse any group.
  • Group by. Switch grouping to Priority, Assignee or None.
  • Inline editing. Click a status circle, an assignee, a due date or a priority flag to change it in place.
  • Subtasks. Parents show a subtask count and expand to indented rows. Expand or collapse them all from the toolbar.
  • Drag and drop. Drag the handle to reorder. Drop a task in another group to move it there, so dropping into Done completes it.
  • Select and act. Tick rows to bring up the dark bar to set status or priority, or delete.
  • One toolbar. Group, Subtasks, Filters, Me mode, Show closed, Columns and Search sit in one row.
  • Task panel. Click a task name to edit every field, the description and its subtasks.
  • Due dates read as Today, Tomorrow or a weekday, and turn red when overdue.
  • Phones get compact rows like the ClickUp app.
  • Motion and sound. Groups and subtasks open with the transitions.dev reveal, the selection bar fades in, checkboxes draw and switches bounce. Cuelume plays select and drop on drag, success when a task is completed, and close when tasks are deleted.
  • Saved. This demo saves to your browser.

Built with#

Button, Checkbox, Avatar, Badge, Calendar, Dropdown Menu, Popover, Input, Input Group, Textarea, Label, Switch, Tabs and Sheet, with dnd-kit for dragging. It shares its toolbar buttons and single choice menus with the Airtable blocks.

Code#

components/blocks/clickup-list.tsx
"use client"

import * as React from "react"
import {
  closestCenter,
  DndContext,
  DragOverlay,
  KeyboardSensor,
  MouseSensor,
  TouchSensor,
  useDroppable,
  useSensor,
  useSensors,
  type DragEndEvent,
  type DragStartEvent,
} from "@dnd-kit/core"
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
  ArrowDownIcon,
  ArrowUpIcon,
  CalendarBlankIcon,
  CaretDownIcon,
  CaretRightIcon,
  CheckCircleIcon,
  CircleDashedIcon,
  CircleHalfTiltIcon,
  CircleNotchIcon,
  ColumnsIcon,
  DotsSixVerticalIcon,
  DotsThreeIcon,
  FlagIcon,
  FunnelSimpleIcon,
  KanbanIcon,
  ListBulletsIcon,
  MagnifyingGlassIcon,
  PlusIcon,
  RocketIcon,
  StackIcon,
  TextAlignLeftIcon,
  TrashIcon,
  TreeStructureIcon,
  UserIcon,
  XIcon,
} from "@phosphor-icons/react"
import { cn } from "cn"

import {
  initials,
  MenuChoice,
  MenuChoices,
  PALETTE,
  TINT,
  tint,
  ToolbarButton,
  uid,
} from "@/components/blocks/airtable"
import { cue } from "@/lib/sounds"
import { FavoriteButton } from "@/components/ui/favorite-button"
import { ScrollFadeArea } from "@/components/ui/scroll-fade-area"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Card } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"
import { Label } from "@/components/ui/label"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
} from "@/components/ui/sheet"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
  Table,
  TableBody,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
import { Toggle } from "@/components/ui/toggle"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

/* Model */

export type StatusKey = "todo" | "progress" | "review" | "done"
export type PriorityKey = "urgent" | "high" | "normal" | "low" | "none"
type GroupBy = "status" | "priority" | "assignee" | "none"
type ColumnKey = "assignee" | "due" | "priority" | "tags" | "budget"
type SortKey = "name" | ColumnKey

export type Task = {
  id: string
  name: string
  status: StatusKey
  assignees: string[]
  due: string | null
  priority: PriorityKey
  tags: string[]
  budget: number | null
  description: string
  parent: string | null
}

export const STATUSES: { key: StatusKey; label: string; color: string; icon: React.ElementType }[] = [
  { key: "todo", label: "To do", color: "oklch(55.2% 0.016 285.938)", icon: CircleDashedIcon },
  { key: "progress", label: "In progress", color: "oklch(62.3% 0.214 259.815)", icon: CircleHalfTiltIcon },
  { key: "review", label: "Review", color: "oklch(70.5% 0.213 47.604)", icon: CircleNotchIcon },
  { key: "done", label: "Done", color: "oklch(62.7% 0.17 149.214)", icon: CheckCircleIcon },
]

export const PRIORITIES: { key: PriorityKey; label: string; color: string }[] = [
  { key: "urgent", label: "Urgent", color: "oklch(63.7% 0.237 25.331)" },
  { key: "high", label: "High", color: "oklch(79.5% 0.184 86.047)" },
  { key: "normal", label: "Normal", color: "oklch(62.3% 0.214 259.815)" },
  { key: "low", label: "Low", color: "oklch(70.7% 0.022 261.325)" },
  { key: "none", label: "No priority", color: "var(--muted-foreground)" },
]

export const PEOPLE = ["Maya Chen", "Liam Ortiz", "Ava Patel", "Noah Kim"]
export const ME = "Maya Chen"
export const TAGS = ["Design", "PMM", "Web", "Content", "Agent"]

const COLUMNS: { key: ColumnKey; label: string; width: string }[] = [
  { key: "assignee", label: "Assignee", width: "8.5rem" },
  { key: "due", label: "Due date", width: "7.5rem" },
  { key: "priority", label: "Priority", width: "7.5rem" },
  { key: "tags", label: "Tags", width: "10rem" },
  { key: "budget", label: "Budget", width: "6.5rem" },
]

// A fixed "today" keeps the demo the same on the server and in the browser.
export const TODAY = "2026-10-10"

type Seed = [string, StatusKey, string[], string | null, PriorityKey, string[], number | null, string?]
const SEED: Seed[] = [
  ["Social campaign", "done", ["Liam Ortiz"], "2026-10-02", "low", ["Design"], 1200],
  ["Website assets", "done", ["Ava Patel", "Noah Kim"], "2026-10-06", "urgent", ["PMM"], 3400],
  ["Landing page", "done", ["Maya Chen"], "2026-10-05", "normal", ["Design"], 900, "Website assets"],
  ["About page", "done", ["Noah Kim"], "2026-10-06", "high", ["Web"], 400, "Website assets"],
  ["Mobile assets", "done", ["Liam Ortiz"], "2026-10-08", "normal", ["Design"], 800],
  ["Market research analysis", "progress", ["Maya Chen"], "2026-10-12", "high", ["Agent"], 2500],
  ["Competitor benchmarking", "progress", ["Ava Patel", "Maya Chen"], "2026-10-11", "normal", ["PMM"], 1800],
  ["Pricing teardown", "progress", ["Ava Patel"], "2026-10-14", "low", ["PMM"], 300, "Competitor benchmarking"],
  ["Brand positioning strategy", "progress", ["Noah Kim"], "2026-10-09", "urgent", ["Content"], 4200],
  ["Messaging doc", "review", ["Maya Chen"], "2026-10-10", "high", ["Content"], 600],
  ["Launch video storyboard", "review", ["Liam Ortiz"], "2026-10-15", "normal", ["Design", "Content"], 5000],
  ["Email announcement", "todo", ["Ava Patel"], "2026-10-17", "normal", ["Content"], 250],
  ["Update key objectives", "todo", [], "2026-10-21", "none", [], null],
  ["Send invites", "todo", ["Noah Kim"], "2026-10-13", "low", ["PMM"], 150],
  ["Refreshments budget", "todo", ["Maya Chen", "Liam Ortiz"], null, "high", [], 1100],
]

export function seedTasks(): Task[] {
  const byName = new Map<string, string>()
  return SEED.map(([name, status, assignees, due, priority, tags, budget, parent], i) => {
    const id = `t${i + 1}`
    byName.set(name, id)
    return {
      id,
      name,
      status,
      assignees,
      due,
      priority,
      tags,
      budget,
      description: "",
      parent: parent ? (byName.get(parent) ?? null) : null,
    }
  })
}

/* Helpers */

export const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
export const status = (k: StatusKey) => STATUSES.find((s) => s.key === k)!
export const priority = (k: PriorityKey) => PRIORITIES.find((p) => p.key === k)!
const tagColor = (t: string) => PALETTE[(TAGS.indexOf(t) + 1) % PALETTE.length]
const personColor = (p: string) => PALETTE[(PEOPLE.indexOf(p) + 5) % PALETTE.length]

export function dayDiff(iso: string) {
  return Math.round((Date.parse(iso + "T12:00:00") - Date.parse(TODAY + "T12:00:00")) / 86400000)
}

export function dueLabel(iso: string) {
  const diff = dayDiff(iso)
  if (diff === 0) return "Today"
  if (diff === 1) return "Tomorrow"
  if (diff === -1) return "Yesterday"
  const d = new Date(iso + "T12:00:00")
  if (diff > 1 && diff < 7) return d.toLocaleDateString("en-US", { weekday: "long" })
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
}

function toIso(d: Date) {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}

/* Small pieces */

export function StatusIcon({ value, className }: { value: StatusKey; className?: string }) {
  const s = status(value)
  return (
    <s.icon weight={value === "done" ? "bold" : "duotone"} className={cn("size-4 shrink-0", className)} style={{ color: s.color }} />
  )
}

export function Flag({ value, label }: { value: PriorityKey; label?: boolean }) {
  const p = priority(value)
  return (
    <span className="flex items-center gap-1.5">
      <FlagIcon weight="duotone" className="size-4 shrink-0" style={{ color: p.color }} />
      {label && <span className={cn("truncate", value === "none" && "text-muted-foreground")}>{value === "none" ? "" : p.label}</span>}
    </span>
  )
}

export function People({ names, max = 3 }: { names: string[]; max?: number }) {
  if (!names.length) return <UserIcon weight="duotone" className="size-4 text-muted-foreground" />
  return (
    <span className="flex -space-x-1.5">
      {names.slice(0, max).map((n) => (
        <Avatar key={n} className="size-6 ring-2 ring-card">
          <AvatarFallback style={tint(personColor(n))} className={cn("text-[0.6rem] font-medium", TINT)}>
            {initials(n)}
          </AvatarFallback>
        </Avatar>
      ))}
    </span>
  )
}

export function Tag({ value }: { value: string }) {
  return (
    <Badge variant="secondary" style={tint(tagColor(value))} className={cn("h-5 shrink-0 rounded-md border-0 px-1.5 font-normal", TINT)}>
      {value}
    </Badge>
  )
}

/* Pickers shared by rows, the bulk bar and the task panel */

export function StatusMenu({ value, onChange, children }: { value?: StatusKey; onChange: (v: StatusKey) => void; children: React.ReactNode }) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
      <DropdownMenuContent align="start" className="w-48">
        <DropdownMenuLabel>Status</DropdownMenuLabel>
        <MenuChoices value={value ?? ""} onValueChange={(v) => onChange(v as StatusKey)}>
          {STATUSES.map((s) => (
            <MenuChoice key={s.key} value={s.key}>
              <StatusIcon value={s.key} />
              {s.label}
            </MenuChoice>
          ))}
        </MenuChoices>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

export function PriorityMenu({ value, onChange, children }: { value?: PriorityKey; onChange: (v: PriorityKey) => void; children: React.ReactNode }) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
      <DropdownMenuContent align="start" className="w-44">
        <DropdownMenuLabel>Priority</DropdownMenuLabel>
        <MenuChoices value={value ?? ""} onValueChange={(v) => onChange(v as PriorityKey)}>
          {PRIORITIES.map((p) => (
            <MenuChoice key={p.key} value={p.key}>
              <Flag value={p.key} />
              {p.label}
            </MenuChoice>
          ))}
        </MenuChoices>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

export function AssigneePicker({ value, onChange, children }: { value: string[]; onChange: (v: string[]) => void; children: React.ReactNode }) {
  return (
    <Popover>
      <PopoverTrigger asChild>{children}</PopoverTrigger>
      <PopoverContent align="start" className="w-56 p-1">
        {PEOPLE.map((p) => (
          <label key={p} className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted">
            <Checkbox
              checked={value.includes(p)}
              onCheckedChange={(on) => onChange(on ? [...value, p] : value.filter((x) => x !== p))}
            />
            <People names={[p]} />
            <span className="truncate">{p}</span>
          </label>
        ))}
      </PopoverContent>
    </Popover>
  )
}

export function DuePicker({ value, onChange, children }: { value: string | null; onChange: (v: string | null) => void; children: React.ReactNode }) {
  return (
    <Popover>
      <PopoverTrigger asChild>{children}</PopoverTrigger>
      <PopoverContent align="start" className="w-auto p-0">
        <Calendar
          mode="single"
          selected={value ? new Date(value + "T12:00:00") : undefined}
          defaultMonth={new Date((value ?? TODAY) + "T12:00:00")}
          onSelect={(d) => onChange(d ? toIso(d) : null)}
        />
      </PopoverContent>
    </Popover>
  )
}

/* Rows: Table primitives, with inline Buttons for every picker */

type RowProps = {
  task: Task
  depth: number
  subtasks: Task[]
  expanded: boolean
  columns: ColumnKey[]
  selected: boolean
  onToggleExpand: () => void
  onSelect: (on: boolean) => void
  onOpen: () => void
  update: (patch: Partial<Task>) => void
  dragHandle?: React.HTMLAttributes<HTMLButtonElement>
  dragging?: boolean
  rowRef?: (el: HTMLTableRowElement | null) => void
  rowStyle?: React.CSSProperties
  className?: string
}

function TaskRow({
  task,
  depth,
  subtasks,
  expanded,
  columns,
  selected,
  onToggleExpand,
  onSelect,
  onOpen,
  update,
  dragHandle,
  dragging,
  rowRef,
  rowStyle,
  className,
}: RowProps) {
  const overdue = task.due && task.status !== "done" && dayDiff(task.due) < 0
  return (
    <TableRow
      ref={rowRef}
      style={rowStyle}
      data-state={selected ? "selected" : undefined}
      className={cn("group/row text-[13px] data-[state=selected]:bg-sky-500/10", dragging && "opacity-40", className)}
    >
      {/* Name: handle, checkbox, subtask caret, status, name and badges */}
      <TableCell className="py-1 pr-2" style={{ paddingLeft: `${depth * 1.5}rem` }}>
        <div className="flex min-w-0 items-center gap-1">
          {dragHandle ? (
            <Button
              variant="ghost"
              size="icon-xs"
              aria-label="Drag to reorder"
              className="shrink-0 cursor-grab text-muted-foreground opacity-0 group-hover/row:opacity-100 active:cursor-grabbing"
              {...dragHandle}
            >
              <DotsSixVerticalIcon weight="bold" />
            </Button>
          ) : (
            <span className="size-7 shrink-0" />
          )}
          <Checkbox
            aria-label={`Select ${task.name}`}
            checked={selected}
            onCheckedChange={(on) => onSelect(!!on)}
            className={cn("mx-1 shrink-0 opacity-0 group-hover/row:opacity-100", selected && "opacity-100")}
          />
          {subtasks.length ? (
            <Button
              type="button"
              variant="ghost"
              size="icon-xs"
              aria-label={expanded ? "Collapse subtasks" : "Expand subtasks"}
              className="shrink-0 text-muted-foreground"
              onClick={onToggleExpand}
            >
              {expanded ? <CaretDownIcon weight="bold" /> : <CaretRightIcon weight="bold" />}
            </Button>
          ) : (
            <span className="size-7 shrink-0" />
          )}
          <StatusMenu value={task.status} onChange={(s) => update({ status: s })}>
            <Button type="button" variant="ghost" size="inline" aria-label={`Status: ${status(task.status).label}`} className="shrink-0">
              <StatusIcon value={task.status} />
            </Button>
          </StatusMenu>
          <Button
            type="button"
            variant="ghost"
            size="inline"
            onClick={onOpen}
            className={cn("min-w-0 shrink font-medium", task.status === "done" && "text-muted-foreground")}
          >
            <span className="truncate">{task.name}</span>
          </Button>
          {subtasks.length > 0 && (
            <span className="flex shrink-0 items-center gap-0.5 text-xs text-muted-foreground">
              <TreeStructureIcon weight="duotone" className="size-3.5" />
              {subtasks.length}
            </span>
          )}
          {task.description && <TextAlignLeftIcon weight="bold" className="size-3.5 shrink-0 text-muted-foreground" />}
        </div>
      </TableCell>
      {columns.map((c) => (
        <TableCell key={c} className={cn("px-2 py-1", c === "budget" && "text-right")}>
          {c === "assignee" && (
            <AssigneePicker value={task.assignees} onChange={(v) => update({ assignees: v })}>
              <Button type="button" variant="ghost" size="inline" aria-label="Assignees">
                <People names={task.assignees} />
              </Button>
            </AssigneePicker>
          )}
          {c === "due" && (
            <DuePicker value={task.due} onChange={(v) => update({ due: v })}>
              <Button
                type="button"
                variant="ghost"
                size="inline"
                aria-label="Due date"
                className={cn(overdue && "text-red-600 dark:text-red-400", !task.due && "text-muted-foreground")}
              >
                {task.due ? dueLabel(task.due) : <CalendarBlankIcon weight="duotone" />}
              </Button>
            </DuePicker>
          )}
          {c === "priority" && (
            <PriorityMenu value={task.priority} onChange={(v) => update({ priority: v })}>
              <Button type="button" variant="ghost" size="inline" aria-label="Priority">
                <Flag value={task.priority} label />
              </Button>
            </PriorityMenu>
          )}
          {c === "tags" && (
            <span className="flex min-w-0 gap-1 overflow-hidden">
              {task.tags.map((t) => (
                <Tag key={t} value={t} />
              ))}
            </span>
          )}
          {c === "budget" && (
            <span className="tabular-nums">
              {task.budget != null ? money.format(task.budget) : <span className="text-muted-foreground">-</span>}
            </span>
          )}
        </TableCell>
      ))}
    </TableRow>
  )
}

function SortableTask(props: Omit<RowProps, "dragHandle" | "dragging" | "rowRef" | "rowStyle">) {
  const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
    id: props.task.id,
    transition: { duration: 220, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
  })
  return (
    <TaskRow
      {...props}
      rowRef={setNodeRef}
      rowStyle={{ transform: CSS.Translate.toString(transform), transition, position: "relative", zIndex: isDragging ? 1 : undefined }}
      dragHandle={{ ...attributes, ...listeners }}
      dragging={isDragging}
    />
  )
}

/* Phones: Item rows, like the ClickUp app */

function PhoneRow({
  task,
  sub,
  selected,
  onSelect,
  onOpen,
  update,
}: {
  task: Task
  sub: number
  selected: boolean
  onSelect: (on: boolean) => void
  onOpen: () => void
  update: (p: Partial<Task>) => void
}) {
  const overdue = task.due && task.status !== "done" && dayDiff(task.due) < 0
  return (
    <Item
      size="sm"
      data-state={selected ? "selected" : undefined}
      className="items-start rounded-none border-0 border-b px-2 data-[state=selected]:bg-sky-500/10"
    >
      <ItemMedia className="gap-1 pt-0.5">
        <Checkbox aria-label={`Select ${task.name}`} checked={selected} onCheckedChange={(on) => onSelect(!!on)} />
        <StatusMenu value={task.status} onChange={(s) => update({ status: s })}>
          <Button type="button" variant="ghost" size="inline" aria-label={`Status: ${status(task.status).label}`}>
            <StatusIcon value={task.status} />
          </Button>
        </StatusMenu>
      </ItemMedia>
      <ItemContent className="min-w-0 gap-1">
        <ItemTitle className="w-full">
          <Button
            type="button"
            variant="ghost"
            size="inline"
            onClick={onOpen}
            className={cn("-ml-1 max-w-full font-medium", task.status === "done" && "text-muted-foreground")}
          >
            <span className="truncate">{task.name}</span>
          </Button>
        </ItemTitle>
        <ItemDescription className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
            {task.due && <span className={cn(overdue && "text-red-600 dark:text-red-400")}>{dueLabel(task.due)}</span>}
            {task.priority !== "none" && <Flag value={task.priority} label />}
            {sub > 0 && (
              <span className="flex items-center gap-0.5">
                <TreeStructureIcon weight="duotone" className="size-3.5" />
                {sub}
              </span>
            )}
            {task.tags.map((t) => (
              <Tag key={t} value={t} />
            ))}
        </ItemDescription>
      </ItemContent>
      <ItemActions>
        <People names={task.assignees} max={2} />
      </ItemActions>
    </Item>
  )
}

/* Groups */

function GroupDrop({ id, children, className }: { id: string; children: React.ReactNode; className?: string }) {
  const { setNodeRef, isOver } = useDroppable({ id })
  return (
    <div ref={setNodeRef} className={cn("rounded-(--ui-radius) transition-colors", isOver && "bg-sky-500/5", className)}>
      {children}
    </div>
  )
}

export function AddTaskForm({ onAdd, inset = true }: { onAdd: (name: string) => void; inset?: boolean }) {
  const [adding, setAdding] = React.useState(false)
  const [name, setName] = React.useState("")
  if (!adding)
    return (
      <Button
        type="button"
        variant="ghost"
        size="sm"
        className={cn("w-full justify-start font-normal text-muted-foreground", inset && "pl-[5.75rem]")}
        onClick={() => setAdding(true)}
      >
        <PlusIcon weight="bold" />
        Add task
      </Button>
    )
  return (
    <form
      className={cn("flex items-center gap-2 py-1 pr-2", inset ? "pl-[5.75rem]" : "pl-2")}
      onSubmit={(e) => {
        e.preventDefault()
        if (name.trim()) onAdd(name.trim())
        setName("")
      }}
    >
      <Input
        autoFocus
        value={name}
        placeholder="Task name, then Enter"
        className="h-8"
        onChange={(e) => setName(e.target.value)}
        onKeyDown={(e) => e.key === "Escape" && setAdding(false)}
        onBlur={() => !name && setAdding(false)}
      />
      <Button type="submit" size="sm">
        Save
      </Button>
    </form>
  )
}

/* Task panel: every field, the description and subtasks. Shared with ClickUp Kanban. */

export function TaskPanel({
  tasks,
  task: openTask,
  listName,
  update,
  remove,
  addTask,
  onClose,
}: {
  tasks: Task[]
  task: Task | undefined
  listName: string
  update: (id: string, patch: Partial<Task>) => void
  remove: (ids: string[]) => void
  addTask: (name: string, patch?: Partial<Task>) => string
  onClose: () => void
}) {
  const childrenOf = (id: string) => tasks.filter((t) => t.parent === id)
  return (
  <Sheet open={!!openTask} onOpenChange={(o) => !o && onClose()}>
    <SheetContent className="w-full gap-0 p-0 sm:max-w-lg">
      {openTask && (
        <>
          <SheetHeader className="gap-2 border-b">
            <div className="flex items-center gap-2 pr-8">
              <StatusMenu value={openTask.status} onChange={(s) => update(openTask.id, { status: s })}>
                <Button type="button" variant="outline" size="sm" className="gap-1.5">
                  <StatusIcon value={openTask.status} />
                  {status(openTask.status).label}
                  <CaretDownIcon weight="bold" />
                </Button>
              </StatusMenu>
              {openTask.parent && (
                <span className="truncate text-xs text-muted-foreground">
                  Subtask of {tasks.find((t) => t.id === openTask.parent)?.name}
                </span>
              )}
            </div>
            <SheetTitle className="sr-only">{openTask.name}</SheetTitle>
            <Input
              aria-label="Task name"
              value={openTask.name}
              onChange={(e) => update(openTask.id, { name: e.target.value })}
              className="h-auto py-1.5 text-xl font-semibold"
            />
            <SheetDescription>{listName}</SheetDescription>
          </SheetHeader>
          <div className="flex flex-1 flex-col gap-5 overflow-y-auto p-4">
            <div className="grid grid-cols-[7rem_1fr] items-center gap-x-3 gap-y-3">
              <Label className="font-normal text-muted-foreground">Assignees</Label>
              <AssigneePicker value={openTask.assignees} onChange={(v) => update(openTask.id, { assignees: v })}>
                <Button type="button" variant="ghost" size="sm" className="w-fit">
                  <People names={openTask.assignees} />
                  {openTask.assignees.length ? openTask.assignees.join(", ") : "Add"}
                </Button>
              </AssigneePicker>
              <Label className="font-normal text-muted-foreground">Due date</Label>
              <DuePicker value={openTask.due} onChange={(v) => update(openTask.id, { due: v })}>
                <Button type="button" variant="ghost" size="sm" className="w-fit">
                  <CalendarBlankIcon weight="duotone" />
                  {openTask.due ? dueLabel(openTask.due) : "Set date"}
                </Button>
              </DuePicker>
              <Label className="font-normal text-muted-foreground">Priority</Label>
              <PriorityMenu value={openTask.priority} onChange={(v) => update(openTask.id, { priority: v })}>
                <Button type="button" variant="ghost" size="sm" className="w-fit">
                  <Flag value={openTask.priority} label />
                  {openTask.priority === "none" && "Set priority"}
                </Button>
              </PriorityMenu>
              <Label className="font-normal text-muted-foreground">Tags</Label>
              <ToggleGroup
                type="multiple"
                size="sm"
                spacing={1}
                value={openTask.tags}
                onValueChange={(v) => update(openTask.id, { tags: v })}
                className="flex-wrap"
              >
                {TAGS.map((t) => (
                  <ToggleGroupItem
                    key={t}
                    value={t}
                    aria-label={t}
                    className="h-auto px-0.5 py-0.5 opacity-40 data-[state=on]:bg-transparent data-[state=on]:opacity-100"
                  >
                    <Tag value={t} />
                  </ToggleGroupItem>
                ))}
              </ToggleGroup>
              <Label className="font-normal text-muted-foreground">Budget</Label>
              <Input
                type="number"
                className="h-8"
                value={openTask.budget ?? ""}
                onChange={(e) => update(openTask.id, { budget: e.target.value === "" ? null : Number(e.target.value) })}
              />
            </div>
            <div className="flex flex-col gap-1.5">
              <Label className="font-normal text-muted-foreground">Description</Label>
              <Textarea
                placeholder="Add a description"
                value={openTask.description}
                onChange={(e) => update(openTask.id, { description: e.target.value })}
              />
            </div>
            {!openTask.parent && (
              <div className="flex flex-col gap-1">
                <Label className="font-normal text-muted-foreground">Subtasks</Label>
                {childrenOf(openTask.id).map((st) => (
                  <label key={st.id} className="flex items-center gap-2 rounded-md px-1 py-1.5 hover:bg-muted">
                    <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>
                ))}
                <form
                  className="mt-1"
                  onSubmit={(e) => {
                    e.preventDefault()
                    const input = e.currentTarget.elements.namedItem("subtask") as HTMLInputElement
                    if (input.value.trim()) addTask(input.value.trim(), { parent: openTask.id, status: "todo" })
                    input.value = ""
                  }}
                >
                  <Input name="subtask" placeholder="Add a subtask, then Enter" className="h-8" />
                </form>
              </div>
            )}
          </div>
          <SheetFooter className="flex-row border-t">
            <Button
              type="button"
              variant="destructive"
              onClick={() => {
                remove([openTask.id])
                onClose()
              }}
            >
              <TrashIcon weight="duotone" />
              Delete task
            </Button>
          </SheetFooter>
        </>
      )}
    </SheetContent>
  </Sheet>
  )
}

/* The list */

export type ClickupListProps = {
  /** List name in the header. */
  name?: string
  /** Saves tasks and settings to localStorage under this key. */
  storageKey?: string
  className?: string
}

type Saved = { tasks: Task[]; groupBy: GroupBy; columns: ColumnKey[] }

export function ClickupList({ name = "Marketing", storageKey, className }: ClickupListProps) {
  const [tasks, setTasks] = React.useState<Task[]>(seedTasks)
  const [groupBy, setGroupBy] = React.useState<GroupBy>("status")
  const [columns, setColumns] = React.useState<ColumnKey[]>(["assignee", "due", "priority", "tags", "budget"])
  const [ready, setReady] = React.useState(!storageKey)
  const [query, setQuery] = React.useState("")
  const [meMode, setMeMode] = React.useState(false)
  const [showClosed, setShowClosed] = React.useState(true)
  const [subtasksOpen, setSubtasksOpen] = React.useState(true)
  const [expanded, setExpanded] = React.useState<string[]>([])
  const [collapsed, setCollapsed] = React.useState<string[]>([])
  // Only groups and subtasks the user just opened play the reveal.
  const [revealed, setRevealed] = React.useState<string[]>([])
  const reveal = (key: string) => setRevealed((r) => [...r.filter((x) => x !== key), key])
  const [selected, setSelected] = React.useState<string[]>([])
  const [priorityFilter, setPriorityFilter] = React.useState<PriorityKey[]>([])
  const [tagFilter, setTagFilter] = React.useState<string[]>([])
  const [sort, setSort] = React.useState<{ key: SortKey; dir: "asc" | "desc" } | null>(null)
  const [open, setOpen] = React.useState<string | null>(null)
  const [dragging, setDragging] = React.useState<string | null>(null)
  const dndId = React.useId()

  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)
        setGroupBy(saved.groupBy)
        setColumns(saved.columns)
      }
    } catch {
      // A broken save falls back to the sample list.
    }
    setReady(true)
  }, [storageKey])

  React.useEffect(() => {
    if (!ready || !storageKey) return
    try {
      window.localStorage.setItem(storageKey, JSON.stringify({ tasks, groupBy, columns } satisfies Saved))
    } catch {
      // Storage can be full or blocked.
    }
  }, [tasks, groupBy, columns, ready, storageKey])

  // Completing a task plays Cuelume's success cue, like a saved change.
  const completes = (ids: string[], patch: Partial<Task>) =>
    patch.status === "done" && tasks.some((t) => ids.includes(t.id) && t.status !== "done")
  const update = (id: string, patch: Partial<Task>) => {
    if (completes([id], patch)) cue("success", { emphasis: "subtle", force: true })
    setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, ...patch } : t)))
  }
  const updateMany = (ids: string[], patch: Partial<Task>) => {
    if (completes(ids, patch)) cue("success", { force: true })
    setTasks((ts) => ts.map((t) => (ids.includes(t.id) ? { ...t, ...patch } : t)))
  }
  const remove = (ids: string[]) => {
    cue("close", { emphasis: "strong", force: true })
    setTasks((ts) => ts.filter((t) => !ids.includes(t.id) && !ids.includes(t.parent ?? "")))
    setSelected((s) => s.filter((id) => !ids.includes(id)))
  }
  const addTask = (name: string, patch: Partial<Task> = {}) => {
    const t: Task = {
      id: `t-${uid()}`,
      name,
      status: "todo",
      assignees: meMode ? [ME] : [],
      due: null,
      priority: "none",
      tags: [],
      budget: null,
      description: "",
      parent: null,
      ...patch,
    }
    setTasks((ts) => [...ts, t])
    if (t.parent) setExpanded((e) => [...new Set([...e, t.parent!])])
    return t.id
  }

  const keyOf = (t: Task): string =>
    groupBy === "status" ? t.status : groupBy === "priority" ? t.priority : groupBy === "assignee" ? (t.assignees[0] ?? "") : "all"

  const groups: { key: string; label: string; color: string; icon?: React.ReactNode }[] =
    groupBy === "status"
      ? STATUSES.filter((s) => showClosed || s.key !== "done").map((s) => ({
          key: s.key,
          label: s.label,
          color: s.color,
          icon: <s.icon weight={s.key === "done" ? "bold" : "duotone"} className="size-3.5" />,
        }))
      : groupBy === "priority"
        ? PRIORITIES.map((p) => ({ key: p.key, label: p.label, color: p.color, icon: <FlagIcon weight="duotone" className="size-3.5" /> }))
        : groupBy === "assignee"
          ? [...PEOPLE.map((p) => ({ key: p, label: p, color: personColor(p) })), { key: "", label: "Unassigned", color: "oklch(55.2% 0.016 285.938)" }]
          : [{ key: "all", label: "All tasks", color: "oklch(55.2% 0.016 285.938)" }]

  const q = query.trim().toLowerCase()
  const visible = (t: Task) =>
    (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 childrenOf = (id: string) => tasks.filter((t) => t.parent === id)
  const parents = tasks.filter((t) => !t.parent)

  const sorted = (list: Task[]) => {
    if (!sort) return list
    const rank = (t: Task): string | number =>
      sort.key === "name" ? t.name : sort.key === "assignee" ? (t.assignees[0] ?? "~") :
      sort.key === "due" ? (t.due ?? "9999") : sort.key === "priority" ? PRIORITIES.findIndex((p) => p.key === t.priority) :
      sort.key === "budget" ? (t.budget ?? -1) : t.tags.join(",")
    return [...list].sort((a, b) => {
      const x = rank(a), y = rank(b)
      const c = typeof x === "number" && typeof y === "number" ? x - y : String(x).localeCompare(String(y))
      return sort.dir === "asc" ? c : -c
    })
  }

  const rowsOf = (key: string) => sorted(parents.filter((t) => keyOf(t) === key && (visible(t) || childrenOf(t.id).some(visible))))
  const isExpanded = (id: string) => (subtasksOpen ? !expanded.includes(id) : expanded.includes(id))
  const toggleExpand = (id: string) => {
    reveal(id)
    setExpanded((e) => (e.includes(id) ? e.filter((x) => x !== id) : [...e, id]))
  }

  const shown = columns
  const shownColumns = COLUMNS.filter((c) => shown.includes(c.key)).map((c) => c.key)

  const sensors = useSensors(
    useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
    useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 6 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  )

  const onDragStart = ({ active }: DragStartEvent) => {
    setDragging(String(active.id))
    cue("select", { force: true })
  }

  // Dropping a task in another group moves it there, so dragging into "Done"
  // completes it, just as in ClickUp.
  const onDragEnd = ({ active, over }: DragEndEvent) => {
    setDragging(null)
    if (!over) return cue("close", { emphasis: "subtle", force: true })
    cue("tap", { emphasis: "strong", force: true })
    const id = String(active.id)
    const overId = String(over.id)
    const task = tasks.find((t) => t.id === id)
    if (!task) return
    const targetKey = overId.startsWith("group:") ? overId.slice(6) : keyOf(tasks.find((t) => t.id === overId) ?? task)
    if (targetKey !== keyOf(task) && groupBy !== "none") {
      const patch: Partial<Task> =
        groupBy === "status" ? { status: targetKey as StatusKey } :
        groupBy === "priority" ? { priority: targetKey as PriorityKey } :
        { assignees: targetKey ? [targetKey, ...task.assignees.filter((a) => a !== targetKey)] : [] }
      update(id, patch)
    }
    if (!overId.startsWith("group:") && overId !== id) {
      setSort(null)
      setTasks((ts) => arrayMove(ts, ts.findIndex((t) => t.id === id), ts.findIndex((t) => t.id === overId)))
    }
  }

  const openTask = tasks.find((t) => t.id === open)
  const dragTask = tasks.find((t) => t.id === dragging)
  const filterCount = priorityFilter.length + tagFilter.length

  const headerCell = (key: SortKey, label: string, right = false) => (
    <Button
      type="button"
      variant="ghost"
      size="inline"
      onClick={() => setSort((s) => (s?.key === key ? (s.dir === "asc" ? { key, dir: "desc" } : null) : { key, dir: "asc" }))}
      className={cn("group/sort text-xs font-normal text-muted-foreground", right && "ml-auto")}
    >
      {label}
      {sort?.key === key ? (
        sort.dir === "asc" ? (
          <ArrowUpIcon weight="bold" className="size-3 text-sky-600" />
        ) : (
          <ArrowDownIcon weight="bold" className="size-3 text-sky-600" />
        )
      ) : (
        <ArrowUpIcon weight="bold" className="size-3 opacity-0 group-hover/sort:opacity-40" />
      )}
    </Button>
  )

  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="list">
          <TabsList>
            <TabsTrigger value="list">
              <ListBulletsIcon weight="bold" />
              List
            </TabsTrigger>
            <TabsTrigger value="board" disabled>
              <KanbanIcon weight="duotone" />
              Board
            </TabsTrigger>
            <TabsTrigger value="calendar" disabled>
              <CalendarBlankIcon weight="duotone" />
              Calendar
            </TabsTrigger>
          </TabsList>
        </Tabs>
      </div>

      {/* Every list control sits in this one row */}
      <div className="@container/cu flex items-center gap-1 overflow-x-auto border-b px-2 py-1.5 [scrollbar-width:none]">
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <ToolbarButton
              icon={StackIcon}
              label={`Group: ${groupBy === "none" ? "None" : groupBy[0].toUpperCase() + groupBy.slice(1)}`}
              active={groupBy !== "none"}
              tint="bg-violet-500/15 text-violet-900 hover:bg-violet-500/20 dark:text-violet-100"
            />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start">
            <DropdownMenuLabel>Group by</DropdownMenuLabel>
            <MenuChoices value={groupBy} onValueChange={(v) => { setGroupBy(v as GroupBy); setCollapsed([]) }}>
              <MenuChoice value="status">Status</MenuChoice>
              <MenuChoice value="priority">Priority</MenuChoice>
              <MenuChoice value="assignee">Assignee</MenuChoice>
              <MenuChoice value="none">None</MenuChoice>
            </MenuChoices>
          </DropdownMenuContent>
        </DropdownMenu>
        <ToolbarButton
          icon={TreeStructureIcon}
          label={subtasksOpen ? "Subtasks: Expanded" : "Subtasks: Collapsed"}
          tint=""
          onClick={() => { setSubtasksOpen((o) => !o); setExpanded([]) }}
        />
        <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) => (
              <label key={p.key} className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted">
                <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>
            ))}
            <p className="mt-1 border-t px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground">Tags</p>
            {TAGS.map((t) => (
              <label key={t} className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted">
                <Checkbox checked={tagFilter.includes(t)} onCheckedChange={(on) => setTagFilter((f) => (on ? [...f, t] : f.filter((x) => x !== t)))} />
                <Tag value={t} />
              </label>
            ))}
            {filterCount > 0 && (
              <Button type="button" variant="ghost" size="xs" className="mt-1 w-full" onClick={() => { setPriorityFilter([]); setTagFilter([]) }}>
                Clear filters
              </Button>
            )}
          </PopoverContent>
        </Popover>
        {/* On and off settings are Toggle primitives. */}
        <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>
        <Popover>
          <PopoverTrigger asChild>
            <ToolbarButton icon={ColumnsIcon} label="Columns" tint="" />
          </PopoverTrigger>
          <PopoverContent align="start" className="w-56 p-2">
            {COLUMNS.map((c) => (
              <label key={c.key} className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted">
                <Switch
                  checked={columns.includes(c.key)}
                  onCheckedChange={(on) => setColumns((cs) => (on ? [...cs, c.key] : cs.filter((x) => x !== c.key)))}
                />
                {c.label}
              </label>
            ))}
          </PopoverContent>
        </Popover>
        <div className="ml-auto flex shrink-0 items-center gap-1 pl-2">
          {/* On phones the search is an icon until it is focused or holds a query. */}
          <InputGroup
            className={cn(
              "w-32 transition-[width] duration-200 @6xl/cu:w-52",
              !query && "@max-md/cu:w-9 @max-md/cu:focus-within:w-32"
            )}
          >
            <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 @6xl/cu:inline">Task</span>
          </Button>
        </div>
      </div>

      {/* Groups */}
      <DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragStart={onDragStart} onDragEnd={onDragEnd} onDragCancel={() => setDragging(null)}>
        <ScrollFadeArea fade="y" className="max-h-[42rem] px-2 pb-20 sm:px-4">
          {groups.map((g) => {
            const rows = rowsOf(g.key)
            if (groupBy !== "status" && !rows.length) return null
            const isCollapsed = collapsed.includes(g.key)
            const budget = rows.reduce((s, t) => s + (t.budget ?? 0) + childrenOf(t.id).reduce((c, x) => c + (x.budget ?? 0), 0), 0)
            return (
              <GroupDrop key={g.key || "unassigned"} id={`group:${g.key}`} className="mt-4">
                {/* Group header */}
                <div className="flex items-center gap-2 py-1.5">
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon-xs"
                    aria-label={isCollapsed ? `Expand ${g.label}` : `Collapse ${g.label}`}
                    onClick={() => {
                      reveal(`group:${g.key}`)
                      setCollapsed((c) => (c.includes(g.key) ? c.filter((x) => x !== g.key) : [...c, g.key]))
                    }}
                  >
                    {isCollapsed ? <CaretRightIcon weight="bold" /> : <CaretDownIcon weight="bold" />}
                  </Button>
                  <Badge
                    className="h-6 gap-1.5 rounded-md border-0 px-2 text-xs font-semibold tracking-wide text-white uppercase"
                    style={{ background: g.color }}
                  >
                    {g.icon}
                    {g.label}
                  </Badge>
                  <span className="text-xs text-muted-foreground tabular-nums">{rows.length}</span>
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button type="button" variant="ghost" size="icon-xs" aria-label="Group options">
                        <DotsThreeIcon weight="bold" />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="start">
                      <DropdownMenuItem onSelect={() => setSelected((s) => [...new Set([...s, ...rows.map((r) => r.id)])])}>
                        Select all in group
                      </DropdownMenuItem>
                      <DropdownMenuItem onSelect={() => setCollapsed((c) => (c.includes(g.key) ? c : [...c, g.key]))}>
                        Collapse group
                      </DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon-xs"
                    aria-label={`Add task to ${g.label}`}
                    onClick={() =>
                      setOpen(
                        addTask(
                          "Untitled task",
                          groupBy === "status" ? { status: g.key as StatusKey } :
                          groupBy === "priority" ? { priority: g.key as PriorityKey } :
                          groupBy === "assignee" && g.key ? { assignees: [g.key] } : {}
                        )
                      )
                    }
                  >
                    <PlusIcon weight="bold" />
                  </Button>
                </div>

                {!isCollapsed && (
                  <div className={cn(revealed.includes(`group:${g.key}`) && "t-swap-in")}>
                    {/* Desktop: the Table primitive */}
                    <div className="hidden md:block">
                      <Table className="min-w-[52rem] table-fixed">
                        <colgroup>
                          <col />
                          {COLUMNS.filter((c) => shown.includes(c.key)).map((c) => (
                            <col key={c.key} style={{ width: c.width }} />
                          ))}
                        </colgroup>
                        <TableHeader>
                          <TableRow className="hover:bg-transparent">
                            <TableHead className="h-8 pl-[5.5rem]">{headerCell("name", "Name")}</TableHead>
                            {COLUMNS.filter((c) => shown.includes(c.key)).map((c) => (
                              <TableHead key={c.key} className={cn("h-8 px-1", c.key === "budget" && "text-right")}>
                                {headerCell(c.key, c.label, c.key === "budget")}
                              </TableHead>
                            ))}
                          </TableRow>
                        </TableHeader>
                        <TableBody>
                          <SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
                            {rows.map((t) => {
                              const subs = childrenOf(t.id).filter(visible)
                              const open = isExpanded(t.id)
                              return (
                                <React.Fragment key={t.id}>
                                  <SortableTask
                                    task={t}
                                    depth={0}
                                    subtasks={subs}
                                    expanded={open}
                                    columns={shownColumns}
                                    selected={selected.includes(t.id)}
                                    onToggleExpand={() => toggleExpand(t.id)}
                                    onSelect={(on) => setSelected((s) => (on ? [...s, t.id] : s.filter((x) => x !== t.id)))}
                                    onOpen={() => setOpen(t.id)}
                                    update={(p) => update(t.id, p)}
                                  />
                                  {open &&
                                    subs.map((st) => (
                                      <TaskRow
                                        key={st.id}
                                        className={cn(revealed.includes(t.id) && "t-swap-in")}
                                        task={st}
                                        depth={1}
                                        subtasks={[]}
                                        expanded={false}
                                        columns={shownColumns}
                                        selected={selected.includes(st.id)}
                                        onToggleExpand={() => {}}
                                        onSelect={(on) => setSelected((s) => (on ? [...s, st.id] : s.filter((x) => x !== st.id)))}
                                        onOpen={() => setOpen(st.id)}
                                        update={(p) => update(st.id, p)}
                                      />
                                    ))}
                                </React.Fragment>
                              )
                            })}
                          </SortableContext>
                          <TableRow className="hover:bg-transparent">
                            <TableCell colSpan={shownColumns.length + 1} className="p-0">
                              <AddTaskForm
                                onAdd={(n) =>
                                  addTask(
                                    n,
                                    groupBy === "status" ? { status: g.key as StatusKey } :
                                    groupBy === "priority" ? { priority: g.key as PriorityKey } :
                                    groupBy === "assignee" && g.key ? { assignees: [g.key] } : {}
                                  )
                                }
                              />
                            </TableCell>
                          </TableRow>
                        </TableBody>
                        {shown.includes("budget") && rows.length > 0 && (
                          <TableFooter className="border-t-0 bg-transparent font-normal">
                            <TableRow className="hover:bg-transparent">
                              <TableCell />
                              {shownColumns.map((c) => (
                                <TableCell key={c} className="px-2 text-right text-xs text-muted-foreground tabular-nums">
                                  {c === "budget" ? money.format(budget) : ""}
                                </TableCell>
                              ))}
                            </TableRow>
                          </TableFooter>
                        )}
                      </Table>
                    </div>

                    {/* Phones */}
                    <div className="md:hidden">
                      {rows.flatMap((t) => [t, ...(isExpanded(t.id) ? childrenOf(t.id).filter(visible) : [])]).map((t) => (
                        <div key={t.id} className={cn(t.parent && "pl-6")}>
                          <PhoneRow
                            task={t}
                            sub={childrenOf(t.id).length}
                            selected={selected.includes(t.id)}
                            onSelect={(on) => setSelected((s) => (on ? [...s, t.id] : s.filter((x) => x !== t.id)))}
                            onOpen={() => setOpen(t.id)}
                            update={(p) => update(t.id, p)}
                          />
                        </div>
                      ))}
                      <AddTaskForm inset={false} onAdd={(n) => addTask(n, groupBy === "status" ? { status: g.key as StatusKey } : {})} />
                    </div>
                  </div>
                )}
              </GroupDrop>
            )
          })}
        </ScrollFadeArea>
        <DragOverlay dropAnimation={{ duration: 220, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
          {dragTask ? (
            <Table className="min-w-[24rem] rotate-1 rounded-(--ui-radius) bg-card shadow-xl ring-2 ring-sky-500/40">
              <TableBody>
              <TaskRow
                task={dragTask}
                depth={0}
                subtasks={[]}
                expanded={false}
                columns={shownColumns.filter((c) => c !== "tags" && c !== "budget")}
                selected={false}
                onToggleExpand={() => {}}
                onSelect={() => {}}
                onOpen={() => {}}
                update={() => {}}
              />
              </TableBody>
            </Table>
          ) : null}
        </DragOverlay>
      </DndContext>

      {/* Bulk actions: a floating bar, p-1 with --ui-radius buttons inside a concentric shell */}
      {selected.length > 0 && (
        <Card
          size="sm"
          className="t-swap-in absolute inset-x-0 bottom-4 z-10 mx-auto w-fit max-w-[calc(100%-1rem)] flex-row items-center gap-1 overflow-x-auto rounded-[calc(var(--ui-radius)+0.25rem)]! bg-popover p-1! text-popover-foreground shadow-lg [scrollbar-width:none]"
        >
          <span className="shrink-0 px-2 text-sm font-medium tabular-nums">
            {selected.length}
            <span className="hidden sm:inline"> {selected.length === 1 ? "task" : "tasks"}</span> selected
          </span>
          <StatusMenu onChange={(s) => updateMany(selected, { status: s })}>
            <Button type="button" variant="ghost" size="sm" className="shrink-0">
              Status
            </Button>
          </StatusMenu>
          <PriorityMenu onChange={(p) => updateMany(selected, { priority: p })}>
            <Button type="button" variant="ghost" size="sm" className="shrink-0">
              Priority
            </Button>
          </PriorityMenu>
          <Button
            type="button"
            variant="destructive"
            size="sm"
            aria-label="Delete"
            className="shrink-0"
            onClick={() => remove(selected)}
          >
            <TrashIcon weight="duotone" />
            <span className="hidden sm:inline">Delete</span>
          </Button>
          <Button
            type="button"
            variant="ghost"
            size="icon-sm"
            aria-label="Dismiss"
            className="size-[2.125rem]! shrink-0"
            onClick={() => setSelected([])}
          >
            <XIcon weight="bold" />
          </Button>
        </Card>
      )}

      {/* Task panel */}
      <TaskPanel
        tasks={tasks}
        task={openTask}
        listName={name}
        update={update}
        remove={remove}
        addTask={addTask}
        onClose={() => setOpen(null)}
      />
    </Card>
  )
}
Airtable KanbanClickUp Kanban

On This Page

InstallationFeaturesBuilt withCode