SaaS & Ops

Issue Row

A Linear-style issue row with priority glyph, id, title, labels, assignee, status icon and a hover action rail that keeps layout stable.

Install

npx shadcn@latest add @paragon/issue-row

issue-row.tsx

"use client";

import * as React from "react";
import {
  CircleDashed,
  CircleDot,
  CircleCheck,
  Circle,
  Timer,
  SignalHigh,
  SignalMedium,
  SignalLow,
  Minus,
  AlertTriangle,
  Link2,
  MessageSquare,
} from "lucide-react";
import { cn } from "@/lib/utils";

export type IssuePriority = "urgent" | "high" | "medium" | "low" | "none";
export type IssueStatus = "backlog" | "todo" | "in-progress" | "done";

export interface IssueLabel {
  name: string;
  /** CSS color for the label dot. */
  color: string;
}

export interface IssueRowProps
  extends Omit<React.ComponentProps<"div">, "id" | "onSelect"> {
  /** Issue identifier, e.g. "ENG-482". */
  issueId: string;
  title: string;
  priority?: IssuePriority;
  status?: IssueStatus;
  /** Assignee display name — seeds initials + hue. */
  assignee?: string;
  labels?: IssueLabel[];
  /** Comment count in the trailing meta. */
  comments?: number;
  /** Whether the issue is linked to a PR/branch. */
  linked?: boolean;
  onSelect?: () => void;
}

const priorityMeta: Record<
  IssuePriority,
  { icon: React.ElementType; label: string; className: string }
> = {
  urgent: { icon: AlertTriangle, label: "Urgent", className: "text-destructive" },
  high: { icon: SignalHigh, label: "High", className: "text-foreground" },
  medium: { icon: SignalMedium, label: "Medium", className: "text-foreground" },
  low: { icon: SignalLow, label: "Low", className: "text-muted-foreground" },
  none: { icon: Minus, label: "No priority", className: "text-muted-foreground" },
};

const statusMeta: Record<
  IssueStatus,
  { icon: React.ElementType; label: string; className: string }
> = {
  backlog: {
    icon: CircleDashed,
    label: "Backlog",
    className: "text-muted-foreground",
  },
  todo: { icon: Circle, label: "Todo", className: "text-muted-foreground" },
  "in-progress": {
    icon: Timer,
    label: "In Progress",
    className: "text-warning",
  },
  done: { icon: CircleCheck, label: "Done", className: "text-success" },
};

function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return "•";
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

function hueOf(value: string): number {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (hash << 5) - hash + value.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash) % 360;
}

/**
 * A Linear-style issue row: priority glyph, monospace id, title, trailing
 * labels, comment count, assignee avatar and a status icon. On hover (fine
 * pointers only) a quiet action rail fades in from the right — copy link and
 * a status/priority affordance — without shifting the row's layout. The whole
 * row is a real button so it is keyboard-reachable and shows a focus ring.
 */
export function IssueRow({
  issueId,
  title,
  priority = "none",
  status = "todo",
  assignee,
  labels = [],
  comments,
  linked = false,
  onSelect,
  className,
  ...props
}: IssueRowProps) {
  const p = priorityMeta[priority];
  const s = statusMeta[status];
  const PriorityIcon = p.icon;
  const StatusIcon = s.icon;

  return (
    <div
      data-slot="issue-row"
      className={cn(
        "group/row relative flex items-center gap-2.5 rounded-lg px-2.5 py-2",
        "transition-colors duration-(--duration-fast) ease-(--ease-out)",
        "hover:bg-accent/50",
        "focus-within:bg-accent/50",
        className,
      )}
      {...props}
    >
      <span
        aria-label={p.label}
        title={p.label}
        className={cn("flex size-4 shrink-0 items-center justify-center", p.className)}
      >
        <PriorityIcon className="size-4" aria-hidden />
      </span>

      <span className="w-16 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
        {issueId}
      </span>

      <button
        type="button"
        onClick={onSelect}
        className="min-w-0 flex-1 truncate text-left text-sm outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring"
      >
        {title}
      </button>

      {labels.length > 0 && (
        <span className="hidden shrink-0 items-center gap-1.5 sm:flex">
          {labels.map((label) => (
            <span
              key={label.name}
              className="inline-flex items-center gap-1.5 rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground"
            >
              <span
                aria-hidden
                className="size-1.5 rounded-full"
                style={{ backgroundColor: label.color }}
              />
              {label.name}
            </span>
          ))}
        </span>
      )}

      {/* Hover action rail — fades in on hover/focus; layout stays stable. */}
      <span
        className={cn(
          "z-10 flex shrink-0 items-center gap-0.5 opacity-0",
          "pointer-events-none group-hover/row:pointer-events-auto group-focus-within/row:pointer-events-auto",
          "group-hover/row:opacity-100 group-focus-within/row:opacity-100",
          "transition-opacity duration-(--duration-fast) ease-(--ease-out)",
          "motion-reduce:transition-none",
        )}
      >
        <IssueAction label="Copy link">
          <Link2 className="size-3.5" aria-hidden />
        </IssueAction>
      </span>

      <div className="flex shrink-0 items-center gap-2.5">
        {typeof comments === "number" && comments > 0 && (
          <span className="hidden items-center gap-1 text-xs text-muted-foreground tabular-nums sm:flex">
            <MessageSquare className="size-3.5" aria-hidden />
            {comments}
          </span>
        )}
        {linked && (
          <CircleDot
            className="size-3.5 text-muted-foreground"
            aria-label="Linked to a pull request"
          />
        )}
        {assignee ? (
          <span
            title={assignee}
            className="flex size-5 items-center justify-center rounded-full text-[10px] font-medium"
            style={{
              backgroundColor: `oklch(0.9 0.05 ${hueOf(assignee)})`,
              color: `oklch(0.4 0.09 ${hueOf(assignee)})`,
            }}
          >
            {initials(assignee)}
          </span>
        ) : (
          <span
            aria-label="Unassigned"
            className="flex size-5 items-center justify-center rounded-full border border-dashed border-border text-muted-foreground"
          >
            <Circle className="size-2.5" aria-hidden />
          </span>
        )}
        <span
          aria-label={s.label}
          title={s.label}
          className={cn("flex size-4 items-center justify-center", s.className)}
        >
          <StatusIcon className="size-4" aria-hidden />
        </span>
      </div>
    </div>
  );
}

function IssueAction({
  label,
  children,
}: {
  label: string;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      aria-label={label}
      title={label}
      className={cn(
        "pressable relative flex size-6 items-center justify-center rounded-md text-muted-foreground",
        "transition-colors duration-(--duration-fast) hover:bg-accent hover:text-foreground",
        "outline-none focus-visible:ring-2 focus-visible:ring-ring",
        "after:absolute after:top-1/2 after:left-1/2 after:size-9 after:-translate-1/2",
      )}
    >
      {children}
    </button>
  );
}