"use client";
import { Led } from "@unlocalhosted/metalui";
import { CalendarIcon, PersonIcon, TagIcon, XIcon } from "@/components/icons";
import { ActorAvatar } from "@/components/agent";
import { ESTIMATES, PRIORITIES, STATUSES, formatDay, priorityLabel, statusLabel, type Status } from "@/lib/issue-meta";
import { LabelChip, LabelDot, PriorityIcon, StatusIcon } from "./issue-icons";
import { Picker } from "./picker";
import { useTracker } from "./tracker-context";
/**
* How a property trigger looks: `icon` is a quiet ghost key for dense rows, `chip` a compact
* raised cap (composer), `row` a full-width sidebar field that lifts like a list row.
*/
export type PropertyVariant = "icon" | "chip" | "row";
const TRIGGER: Record<PropertyVariant, string> = {
icon: "mu-icon-trigger relative z-10 inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-pill px-1 text-ink2 transition-[background-color,color] enabled:hover:recipe-icon-button-ghost-hover enabled:hover:text-ink aria-expanded:recipe-icon-button-ghost-hover focus-visible:focus-ring-flush",
chip: "mu-icon-trigger inline-flex h-[26px] max-w-52 cursor-pointer items-center gap-1.5 rounded-pill px-2.5 type-ui text-ink recipe-button-compact transition-button-compact active:translate-y-button-travel active:recipe-button-compact-pressed aria-expanded:recipe-button-compact-pressed focus-visible:focus-ring",
row: "mu-icon-trigger flex h-8 w-full min-w-0 items-center gap-2 rounded-row px-2 text-left type-ui text-ink transition-[background,box-shadow] enabled:cursor-pointer enabled:hover:recipe-row-list-hover aria-expanded:recipe-row-list-hover focus-visible:focus-ring-flush",
};
type Control<T> = {
value: T;
onChange: (value: T) => void;
variant?: PropertyVariant;
disabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
export function StatusPicker({ value, onChange, variant = "chip", ...rest }: Control<Status>) {
return (
<Picker
label={`Status: ${statusLabel(value)}`}
placeholder="Change status…"
options={STATUSES.map((s) => ({ value: s.id, label: s.label, icon: <StatusIcon status={s.id} /> }))}
value={value}
onSelect={(v) => onChange(v as Status)}
triggerClassName={TRIGGER[variant]}
{...rest}
>
<StatusIcon status={value} />
{variant !== "icon" && <span className="truncate">{statusLabel(value)}</span>}
</Picker>
);
}
export function PriorityPicker({ value, onChange, variant = "chip", ...rest }: Control<number>) {
return (
<Picker
label={`Priority: ${priorityLabel(value)}`}
placeholder="Set priority…"
options={PRIORITIES.map((p) => ({ value: String(p.id), label: p.label, icon: <PriorityIcon priority={p.id} /> }))}
value={String(value)}
onSelect={(v) => onChange(Number(v))}
triggerClassName={TRIGGER[variant]}
{...rest}
>
<PriorityIcon priority={value} />
{variant !== "icon" && (
<span className={`truncate ${value === 0 && variant === "row" ? "text-ink3" : ""}`}>
{value === 0 && variant === "chip" ? "Priority" : priorityLabel(value)}
</span>
)}
</Picker>
);
}
/** Nobody: an empty sunk disc with the person glyph. */
function Unassigned() {
return (
<span className="well flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-ink3">
<PersonIcon width={10} height={10} />
</span>
);
}
export function AssigneePicker({ value, onChange, variant = "chip", ...rest }: Control<string | null>) {
const { members, viewerId } = useTracker();
const assignee = members.find((m) => m.id === value);
// The viewer first, like Linear's "assign to me".
const sorted = [...members].sort((a, b) => Number(b.id === viewerId) - Number(a.id === viewerId));
return (
<Picker
label={assignee ? `Assignee: ${assignee.username}` : "Assign"}
placeholder="Assign to…"
options={[
{ value: "", label: "No assignee", icon: <PersonIcon width={14} height={14} /> },
...sorted.map((m) => ({
value: m.id,
label: m.id === viewerId ? `${m.username} (you)` : m.agentOf ? `${m.username} (agent of ${m.agentOf})` : m.username,
icon: <ActorAvatar name={m.username} agent={m.agentOf ? m.username : null} size={16} />,
})),
]}
value={value ?? ""}
onSelect={(v) => onChange(v || null)}
triggerClassName={TRIGGER[variant]}
{...rest}
>
{assignee ? <ActorAvatar name={assignee.username} agent={assignee.agentOf ? assignee.username : null} size={variant === "icon" ? 18 : 16} /> : <Unassigned />}
{variant !== "icon" && (
<span className={`truncate ${assignee || variant === "chip" ? "" : "text-ink3"}`}>
{assignee ? assignee.username : variant === "chip" ? "Assignee" : "Unassigned"}
</span>
)}
</Picker>
);
}
export function LabelsPicker({ value, onChange, variant = "chip", ...rest }: Control<string[]>) {
const { labels, createLabel, canTriage } = useTracker();
const chosen = labels.filter((l) => value.includes(l.id));
return (
<Picker
multiple
label="Labels"
placeholder="Add labels…"
options={labels.map((l) => ({ value: l.id, label: l.name, icon: <LabelDot color={l.color} /> }))}
value={value}
onSelect={(v) => onChange(v)}
onCreate={
canTriage
? async (name) => {
const label = await createLabel(name);
if (label) onChange([...value, label.id]);
}
: undefined
}
createLabel={(name) => `Create Label “${name}”`}
triggerClassName={`${TRIGGER[variant]} ${variant === "row" ? "h-auto min-h-8 flex-wrap gap-1 py-1.5" : ""}`}
{...rest}
>
{chosen.length === 0 ? (
<>
<TagIcon width={14} height={14} className="text-ink2" />
{variant !== "icon" && (
<span className={variant === "row" ? "text-ink3" : ""}>{variant === "row" ? "Add label" : "Labels"}</span>
)}
</>
) : variant === "row" ? (
chosen.map((l) => <LabelChip key={l.id} name={l.name} color={l.color} />)
) : (
<>
<span className="flex -space-x-0.5">
{chosen.slice(0, 3).map((l) => (
<LabelDot key={l.id} color={l.color} />
))}
</span>
<span className="truncate">{chosen.length === 1 ? chosen[0].name : `${chosen.length} labels`}</span>
</>
)}
</Picker>
);
}
export function EstimatePicker({ value, onChange, variant = "chip", ...rest }: Control<number | null>) {
return (
<Picker
label={value ? `Estimate: ${value} points` : "Set estimate"}
placeholder="Set estimate…"
options={[
{ value: "", label: "No estimate" },
...ESTIMATES.map((e) => ({ value: String(e), label: `${e} point${e === 1 ? "" : "s"}` })),
]}
value={value === null ? "" : String(value)}
onSelect={(v) => onChange(v ? Number(v) : null)}
triggerClassName={TRIGGER[variant]}
{...rest}
>
<EstimateIcon />
<span className={`truncate ${value === null && variant === "row" ? "text-ink3" : ""}`}>
{value === null ? (variant === "row" ? "No estimate" : "Estimate") : `${value} point${value === 1 ? "" : "s"}`}
</span>
</Picker>
);
}
/** Points: a diamond on the 24 grid at MetalUI's 1.7 stroke. */
export function EstimateIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} className="shrink-0 text-ink2" aria-hidden>
<path d="M12 3 21 12 12 21 3 12Z" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
</svg>
);
}
/** A native date input dressed as a property; an overdue date gets the red LED beside it. */
export function DueDateField({ value, onChange, variant = "chip", disabled }: Omit<Control<string | null>, "open" | "onOpenChange">) {
const { today } = useTracker();
const overdue = !!value && value < today;
const hover = disabled ? "" : variant === "row" ? "cursor-pointer hover:recipe-row-list-hover" : "cursor-pointer";
return (
<span className={`group relative ${TRIGGER[variant]} ${hover} has-[input:focus-visible]:focus-ring-flush`}>
<CalendarIcon width={14} height={14} className="shrink-0 text-ink2" />
<span className={`truncate ${value ? "" : variant === "row" ? "text-ink3" : ""}`}>
{value ? formatDay(value, variant === "row") : variant === "row" ? "No due date" : "Due date"}
</span>
{overdue && (
<span className="inline-flex items-center gap-1.5 text-red" title="Overdue">
<Led kind="failed" size="small" />
<span className="sr-only">Overdue</span>
</span>
)}
{!disabled && (
<input
type="date"
aria-label="Due date"
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
onClick={(e) => e.currentTarget.showPicker?.()}
className="absolute inset-0 cursor-pointer opacity-0"
/>
)}
{!disabled && value && variant === "row" && (
<button
type="button"
aria-label="Clear due date"
onClick={() => onChange(null)}
className="relative ml-auto inline-grid h-5 w-5 shrink-0 place-items-center rounded-pill text-ink3 opacity-0 transition-opacity group-hover:opacity-100 hover:recipe-icon-button-ghost-hover hover:text-ink focus-visible:opacity-100 focus-visible:focus-ring-flush"
>
<XIcon width={11} height={11} />
</button>
)}
</span>
);
}