Detail Task Card

PreviousNext

Task management detail panel with animated assignee chips and editor controls

Docs
uitripledcomponent

Preview

Loading preview…
components/components/cards/shadcnui/detail-task.tsx
"use client";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { AnimatePresence, motion } from "framer-motion";
import {
  Bold,
  ChevronDown,
  Italic,
  List,
  ListOrdered,
  Plus,
  RotateCcw,
  Save,
  Underline,
  X,
} from "lucide-react";
import { useMemo, useState } from "react";

type Priority = "high" | "medium" | "low";

type TeamMember = {
  id: string;
  name: string;
  role: string;
  initials: string;
  accent: string;
};

const allMembers: TeamMember[] = [
  {
    id: "sophia",
    name: "Sophia Williams",
    role: "Product Designer",
    initials: "SW",
    accent: "ring-foreground text-foreground",
  },
  {
    id: "liam",
    name: "Liam Johnson",
    role: "Design Manager",
    initials: "LJ",
    accent: "ring-foreground text-foreground",
  },
  {
    id: "olivia",
    name: "Olivia Smith",
    role: "UX Researcher",
    initials: "OS",
    accent: "ring-foreground text-foreground",
  },
  {
    id: "mia",
    name: "Mia Chen",
    role: "Product Owner",
    initials: "MC",
    accent: "ring-foreground text-foreground",
  },
  {
    id: "ethan",
    name: "Ethan Davis",
    role: "UI Engineer",
    initials: "ED",
    accent: "ring-foreground text-foreground",
  },
];

const priorityMap: Record<
  Priority,
  { label: string; badge: string; dot: string; description: string }
> = {
  high: {
    label: "High",
    badge:
      "border border-destructive/40 bg-destructive/20 text-destructive dark:text-red-400",
    dot: "bg-destructive",
    description: "Requires immediate focus and dedicated resources",
  },
  medium: {
    label: "Medium",
    badge:
      "border border-amber-500/30 bg-amber-500/20 text-amber-600 dark:text-amber-400",
    dot: "bg-amber-500",
    description: "Important but not blocking other work",
  },
  low: {
    label: "Low",
    badge:
      "border border-emerald-500/30 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400",
    dot: "bg-emerald-500",
    description: "Nice-to-have improvements to schedule later",
  },
};

const defaultDescription =
  "The goal is to update the current design system with the latest components and styles. This includes reviewing existing elements, identifying areas for improvement, and implementing changes to ensure consistency and usability across all platforms.";
const maxDescriptionLength = 200;

export function DetailTaskCard() {
  const [title, setTitle] = useState("Edit Design System");
  const [priority, setPriority] = useState<Priority>("high");
  const [assignees, setAssignees] = useState<TeamMember[]>(
    allMembers.slice(0, 3)
  );
  const [description, setDescription] = useState(defaultDescription);
  const [reminderEnabled, setReminderEnabled] = useState(true);
  const [isSaving, setIsSaving] = useState(false);
  const [isSaved, setIsSaved] = useState(false);

  const remainingCharacters = maxDescriptionLength - description.length;

  const availableMembers = useMemo(
    () =>
      allMembers.filter(
        (member) => !assignees.some((assigned) => assigned.id === member.id)
      ),
    [assignees]
  );

  const handleRemoveAssignee = (id: string) => {
    setAssignees((prev) => prev.filter((member) => member.id !== id));
  };

  const handleAddPerson = () => {
    if (availableMembers.length === 0) return;
    const [nextMember] = availableMembers;
    setAssignees((prev) => [...prev, nextMember]);
  };

  const handleReset = () => {
    setTitle("Edit Design System");
    setPriority("high");
    setAssignees(allMembers.slice(0, 3));
    setDescription(defaultDescription);
    setReminderEnabled(true);
    setIsSaved(false);
  };

  const handleSave = () => {
    if (isSaving) return;
    setIsSaving(true);
    setIsSaved(false);
    setTimeout(() => {
      setIsSaving(false);
      setIsSaved(true);
      setTimeout(() => setIsSaved(false), 2000);
    }, 900);
  };

  const toolbarIcons = [Bold, Italic, Underline, List, ListOrdered];

  return (
    <div className="">
      <Card className="group relative w-full overflow-hidden rounded-2xl border border-border/40 bg-background/60 text-foreground backdrop-blur transition-all hover:border-border/60 hover:shadow-lg">
        <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-foreground/[0.04] via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 -z-10" />

        <CardHeader className="relative gap-3 border-b border-border/40 bg-background/40 px-6 py-6">
          <Badge className="w-fit rounded-full bg-primary/15 px-3 py-1 text-[0.65rem] font-medium uppercase tracking-[0.25em] text-primary transition-colors hover:bg-primary hover:text-primary-foreground">
            Task Manager
          </Badge>
          <CardTitle className="text-sm font-semibold uppercase tracking-[0.25em] text-foreground">
            Detail Task Overview
          </CardTitle>
          <CardDescription className="text-sm text-foreground/70">
            Keep your task aligned with team priorities and deliverables.
          </CardDescription>
        </CardHeader>

        <CardContent className="space-y-10 px-6 py-8">
          <motion.div
            initial={{ opacity: 0, y: 12 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.35, ease: "easeOut" }}
            className="grid gap-6 md:grid-cols-2"
          >
            <div className="space-y-3">
              <label
                htmlFor="task-title"
                className="text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60"
              >
                Title Task
              </label>
              <Input
                id="task-title"
                value={title}
                onChange={(event) => setTitle(event.target.value)}
                className="rounded-xl border-border/40 bg-background/40 text-sm transition-colors focus-visible:border-border/60 focus-visible:ring-2 focus-visible:ring-primary/40"
                aria-describedby="task-title-description"
              />
              <p
                id="task-title-description"
                className="text-xs text-foreground/60"
              >
                Keep it short and goal oriented.
              </p>
            </div>

            <div className="space-y-3">
              <span className="text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
                Priority
              </span>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button
                    variant="outline"
                    className="flex w-full items-center justify-between gap-3 rounded-xl border-border/40 bg-background/40 text-sm font-medium text-foreground transition-all hover:border-border/60 hover:bg-background/60"
                  >
                    <span className="flex items-center gap-3">
                      <span
                        className={`h-2.5 w-2.5 rounded-full ${priorityMap[priority].dot}`}
                        aria-hidden="true"
                      />
                      <span>{priorityMap[priority].label}</span>
                    </span>
                    <ChevronDown
                      className="h-4 w-4 text-foreground/60"
                      aria-hidden="true"
                    />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent
                  align="end"
                  className="w-44 rounded-xl border border-border/40 bg-background/70 backdrop-blur"
                >
                  {(Object.keys(priorityMap) as Priority[]).map((option) => (
                    <DropdownMenuItem
                      key={option}
                      onSelect={() => setPriority(option)}
                      className="flex items-center justify-between gap-2 rounded-lg text-sm text-foreground/80 focus:bg-background/60 focus:text-foreground"
                    >
                      <span className="flex items-center gap-2">
                        <span
                          className={`h-2.5 w-2.5 rounded-full ${priorityMap[option].dot}`}
                          aria-hidden="true"
                        />
                        {priorityMap[option].label}
                      </span>
                      {priority === option ? (
                        <Badge
                          className={`rounded-full px-2 py-0.5 text-[0.65rem] font-medium uppercase tracking-[0.15em] ${priorityMap[option].badge}`}
                        >
                          Selected
                        </Badge>
                      ) : null}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuContent>
              </DropdownMenu>
              <p className="text-xs text-foreground/60">
                {priorityMap[priority].description}
              </p>
            </div>
          </motion.div>

          <div className="space-y-4">
            <div className="flex items-center justify-between">
              <span className="text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
                Assign Task To
              </span>
              <Badge className="rounded-full border border-border/40 bg-background/50 px-3 py-1 text-[0.65rem] font-medium uppercase tracking-[0.25em] text-foreground/70 backdrop-blur transition-colors hover:border-border/60 hover:bg-background/70 hover:text-foreground">
                Team
              </Badge>
            </div>

            <div className="flex flex-wrap gap-3">
              <AnimatePresence>
                {assignees.map((member) => (
                  <motion.div
                    layout
                    key={member.id}
                    initial={{ opacity: 0, y: 12 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0, scale: 0.9, y: -8 }}
                    transition={{ duration: 0.2 }}
                    className="flex items-center gap-3 rounded-xl border border-border/40 bg-background/40 px-3 py-2 backdrop-blur transition-colors hover:border-border/60"
                  >
                    <span
                      className={`flex h-8 w-8 items-center justify-center rounded-full bg-background/70 text-xs font-semibold tracking-tight ring-1 ring-border/40 ring-offset-2 ring-offset-background ${member.accent}`}
                    >
                      {member.initials}
                    </span>
                    <div className="flex flex-col text-left">
                      <span className="text-sm font-medium text-foreground">
                        {member.name}
                      </span>
                      <span className="text-xs text-foreground/60">
                        {member.role}
                      </span>
                    </div>
                    <Button
                      variant="ghost"
                      size="icon"
                      onClick={() => handleRemoveAssignee(member.id)}
                      className="h-8 w-8 rounded-lg text-foreground/60 transition-colors hover:text-foreground"
                      aria-label={`Remove ${member.name} from this task`}
                    >
                      <X className="h-4 w-4" />
                    </Button>
                  </motion.div>
                ))}
              </AnimatePresence>
              <Button
                type="button"
                variant="outline"
                onClick={handleAddPerson}
                disabled={availableMembers.length === 0}
                className="flex items-center gap-2 rounded-xl border border-dashed border-border/50 bg-transparent text-sm text-foreground/80 transition-all hover:border-border/70 hover:bg-background/40 disabled:cursor-not-allowed disabled:opacity-70"
              >
                <Plus className="h-4 w-4" />
                Add another person
              </Button>
            </div>
          </div>

          <div className="space-y-3">
            <div className="flex items-center justify-between">
              <span className="text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
                Description
              </span>
              <span className="text-xs text-foreground/60">
                {Math.max(0, remainingCharacters)} / {maxDescriptionLength}
              </span>
            </div>

            <div className="rounded-2xl border border-border/40 bg-background/40 backdrop-blur">
              <div className="flex items-center gap-1 border-b border-border/40 px-3 py-2 text-foreground/60">
                {toolbarIcons.map((Icon, index) => (
                  <Button
                    key={Icon.displayName ?? index}
                    variant="ghost"
                    size="sm"
                    className="h-9 w-9 rounded-lg text-foreground/60 transition-colors hover:bg-background/60 hover:text-foreground"
                    aria-label={`Formatting option ${Icon.displayName ?? index + 1}`}
                  >
                    <Icon className="h-4 w-4" />
                  </Button>
                ))}
              </div>
              <Textarea
                value={description}
                onChange={(event) =>
                  setDescription(
                    event.target.value.slice(0, maxDescriptionLength)
                  )
                }
                className="h-32 resize-none border-0 bg-transparent px-3 py-3 text-sm text-foreground/80 focus-visible:ring-0 focus-visible:ring-offset-0"
                aria-label="Task description"
              />
            </div>
          </div>

          <div className="flex flex-col gap-4 rounded-2xl border border-border/40 bg-background/40 px-4 py-4 backdrop-blur md:flex-row md:items-center md:justify-between">
            <div className="md:flex space-y-2 md:space-y-0 items-center gap-3">
              <motion.button
                type="button"
                role="switch"
                aria-label="Toggle reminder task"
                aria-checked={reminderEnabled}
                onClick={() => setReminderEnabled((prev) => !prev)}
                className={`relative flex h-6 w-12 items-center rounded-full border border-border/50 transition-all ${
                  reminderEnabled ? "bg-primary/20" : "bg-background/60"
                }`}
              >
                <motion.span
                  layout
                  className="absolute left-1 top-1 h-4 w-4 rounded-full bg-primary shadow-lg"
                  animate={{ x: reminderEnabled ? 22 : 0 }}
                  transition={{ type: "spring", stiffness: 400, damping: 32 }}
                />
              </motion.button>
              <div>
                <p className="text-sm font-medium text-foreground">
                  Reminder Task
                </p>
                <p className="text-xs text-foreground/60">
                  {reminderEnabled
                    ? "We will notify the assignees 24 hours before the due date."
                    : "Enable reminders to keep everyone on track."}
                </p>
              </div>
            </div>

            <Badge className="rounded-full border border-border/40 bg-background/60 px-3 py-1 text-[0.65rem] font-medium uppercase tracking-[0.25em] text-foreground/70 backdrop-blur transition-colors hover:border-border/60 hover:bg-background/70 hover:text-foreground">
              Sprint Q4
            </Badge>
          </div>
        </CardContent>

        <CardFooter className="flex flex-col gap-4 border-t border-border/40 bg-background/50 px-6 py-5 backdrop-blur sm:flex-row sm:items-center sm:justify-between">
          <div className="flex items-center gap-2 text-sm text-foreground/60">
            <RotateCcw className="h-4 w-4" aria-hidden="true" />
            <span>Need to start over?</span>
            <Button
              type="button"
              variant="link"
              className="px-0 text-sm text-foreground hover:text-primary"
              onClick={handleReset}
            >
              Reset
            </Button>
          </div>

          <div className="flex items-center gap-3">
            <AnimatePresence mode="popLayout" initial={false}>
              {isSaved ? (
                <motion.span
                  key="saved"
                  initial={{ opacity: 0, y: 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -6 }}
                  transition={{ duration: 0.2 }}
                  className="text-sm text-emerald-600 dark:text-emerald-400"
                >
                  Saved!
                </motion.span>
              ) : null}
            </AnimatePresence>
            <Button
              type="button"
              onClick={handleSave}
              disabled={isSaving}
              className="flex items-center gap-2 rounded-xl text-sm transition-all hover:brightness-105"
            >
              <Save className="h-4 w-4" aria-hidden="true" />
              {isSaving ? "Saving..." : "Save Changes"}
            </Button>
          </div>
        </CardFooter>
      </Card>
    </div>
  );
}

Installation

npx shadcn@latest add @uitripled/detail-task-card-shadcnui

Usage

import { DetailTaskCardShadcnui } from "@/components/detail-task-card-shadcnui"
<DetailTaskCardShadcnui />