A Linear-style issue row with priority glyph, id, title, labels, assignee, status icon and a hover action rail that keeps layout stable.
npx shadcn@latest add @paragon/issue-row"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>
);
}