- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Boardly — Website Relaunch 1.1.0, shown as text in the browser.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import { memo } from "react";
import type { CardData, Member } from "../types";
import { Avatar } from "./Avatar";
import { labelStyle } from "../labelStyles";
import { IconAlert, IconCalendar, IconChecklist, IconFlag, IconGrip, IconMove } from "../icons";
import { formatDueLong, formatDueShort, priorityLabel } from "../utils";
const PRIORITY_COLORS: Record<string, string> = {
low: "#62788b",
medium: "#1d4ed8",
high: "#b45309",
urgent: "#b3261e",
};
export interface CardItemProps {
card: CardData;
columnTitle: string;
member: Member | null;
memberIndex: number;
labelName: (id: string) => string;
overdue: boolean;
isDragged: boolean;
isGrabbed: boolean;
variant?: "board" | "ghost";
suppressClicks: () => boolean;
onOpen: (cardId: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLLIElement>, cardId: string) => void;
onPointerDown: (event: React.PointerEvent<HTMLLIElement>, cardId: string) => void;
onFocus?: (cardId: string) => void;
onMoveClick: (cardId: string, anchor: HTMLElement) => void;
registerEl: (cardId: string, el: HTMLLIElement | null) => void;
}
function checklistProgress(card: CardData): { done: number; total: number } {
const total = card.checklist.length;
const done = card.checklist.filter((item) => item.done).length;
return { done, total };
}
function buildAriaLabel(
card: CardData,
member: Member | null,
labelName: (id: string) => string,
overdue: boolean,
): string {
const parts: string[] = [`Card: ${card.title}.`];
if (card.labels.length > 0) {
parts.push(`Labels: ${card.labels.map(labelName).join(", ")}.`);
}
parts.push(member ? `Assigned to ${member.name}.` : "Unassigned.");
if (card.due) {
parts.push(overdue ? `Overdue. Due ${formatDueLong(card.due)}.` : `Due ${formatDueLong(card.due)}.`);
} else {
parts.push("No due date.");
}
if (card.checklist.length > 0) {
const { done, total } = checklistProgress(card);
parts.push(`Checklist ${done} of ${total} done.`);
}
parts.push(`Priority ${priorityLabel(card.priority)}.`);
parts.push("Press Enter to open details, Space to pick up for moving.");
return parts.join(" ");
}
function CardItemInner({
card,
columnTitle,
member,
memberIndex,
labelName,
overdue,
isDragged,
isGrabbed,
variant = "board",
suppressClicks,
onOpen,
onKeyDown,
onPointerDown,
onFocus,
onMoveClick,
registerEl,
}: CardItemProps) {
const { done, total } = checklistProgress(card);
const isGhost = variant === "ghost";
const handleClick = (event: React.MouseEvent<HTMLLIElement>) => {
const target = event.target as HTMLElement;
if (target.closest("button, a, input, select, textarea")) return;
if (suppressClicks()) return;
onOpen(card.id);
};
const classes = [
"card",
overdue ? "is-overdue" : "",
isDragged ? "is-dragged" : "",
isGrabbed ? "is-grabbed" : "",
isGhost ? "card-ghost" : "",
]
.filter(Boolean)
.join(" ");
return (
<li
ref={isGhost ? undefined : (el) => registerEl(card.id, el)}
className={classes}
data-card-id={card.id}
tabIndex={isGhost ? -1 : 0}
role={isGhost ? undefined : "listitem"}
aria-label={isGhost ? undefined : buildAriaLabel(card, member, labelName, overdue)}
aria-roledescription={isGhost ? undefined : "draggable card"}
aria-grabbed={isGhost ? undefined : isGrabbed}
onClick={isGhost ? undefined : handleClick}
onKeyDown={isGhost ? undefined : (event) => onKeyDown(event, card.id)}
onPointerDown={isGhost ? undefined : (event) => onPointerDown(event, card.id)}
onFocus={isGhost ? undefined : () => onFocus?.(card.id)}
>
<div className="card-top">
<div className="card-labels">
{card.labels.map((labelId, index) => {
const color = labelStyle(labelId, index);
return (
<span
key={labelId}
className="label-chip"
style={{ background: color.bg, color: color.fg, borderColor: color.border }}
>
{labelName(labelId)}
</span>
);
})}
</div>
<span className="card-grip" title="Drag to move" aria-hidden="true" data-drag-handle>
<IconGrip size={15} />
</span>
</div>
<p className="card-title-row">
<span
className="card-priority"
style={{ color: PRIORITY_COLORS[card.priority] ?? PRIORITY_COLORS.medium }}
title={`${priorityLabel(card.priority)} priority`}
>
<IconFlag size={12} />
</span>
<span className="card-title">{card.title}</span>
</p>
<div className="card-meta">
<span className={`card-assignee ${member ? "" : "is-unassigned"}`}>
<Avatar member={member} index={memberIndex} size={20} />
<span className="card-assignee-name">{member ? member.name : "Unassigned"}</span>
</span>
<span className="card-meta-right">
{card.due ? (
<span className={`card-due ${overdue ? "is-overdue" : ""}`}>
{overdue ? <IconAlert size={13} /> : <IconCalendar size={13} />}
{overdue && <span className="card-due-overdue">Overdue</span>}
<time dateTime={card.due}>{formatDueShort(card.due)}</time>
</span>
) : (
<span className="card-due is-empty">
<IconCalendar size={13} />
No due date
</span>
)}
{total > 0 && (
<span
className={`card-checklist ${done === total ? "is-complete" : ""}`}
title={`Checklist: ${done} of ${total} done`}
>
<IconChecklist size={13} />
{done}/{total}
</span>
)}
</span>
</div>
{!isGhost && (
<div className="card-actions">
<button
type="button"
className="card-move-button"
aria-label={`Move card “${card.title}” to another column`}
title={`Move “${card.title}” to another column`}
onClick={(event) => {
event.stopPropagation();
onMoveClick(card.id, event.currentTarget);
}}
onPointerDown={(event) => event.stopPropagation()}
>
<IconMove size={14} />
<span>Move</span>
</button>
</div>
)}
</li>
);
}
export const CardItem = memo(CardItemInner);