Vectrum
Sign In
227 lines · 9.4 KB · TypeScript
"use client";

import { Led } from "@unlocalhosted/metalui";
import { CalendarIcon, PersonIcon, TagIcon, XIcon } from "@/components/icons";
import { Avatar } from "@/components/ui";
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<number | 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: String(m.id),
          label: m.id === viewerId ? `${m.username} (you)` : m.username,
          icon: <Avatar name={m.username} size={16} />,
        })),
      ]}
      value={value === null ? "" : String(value)}
      onSelect={(v) => onChange(v ? Number(v) : null)}
      triggerClassName={TRIGGER[variant]}
      {...rest}
    >
      {assignee ? <Avatar name={assignee.username} 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<number[]>) {
  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: String(l.id), label: l.name, icon: <LabelDot color={l.color} /> }))}
      value={value.map(String)}
      onSelect={(v) => onChange(v.map(Number))}
      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>
  );
}