- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Boardly — Website Relaunch 1.0.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 { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { formatDue, isOverdue, memberName } from "./data";
import { Avatar, Icon, LabelChip, Menu, VisuallyHidden } from "./ui";
import type { Card, Column } from "./types";
/* ---------- Drag state shared with App ---------- */
export interface DragState {
cardId: string;
fromColumnId: string;
overColumnId: string;
overIndex: number; // index among visible cards of the target column (dragged card excluded)
blocked: boolean;
width: number;
height: number;
}
export interface KeyboardLift {
cardId: string;
origin: { columnId: string; index: number };
}
/* ---------- Card content (shared by the card and the drag ghost) ---------- */
export function CardContent({ card, columnId, titleId }: { card: Card; columnId: string; titleId?: string }) {
const overdue = isOverdue(card, columnId);
const done = card.checklist.filter((i) => i.done).length;
const total = card.checklist.length;
const isDone = columnId === "done";
return (
<>
{card.labels.length > 0 && (
<div className="card-labels" aria-label="Labels">
{card.labels.map((l) => (
<LabelChip key={l} id={l} muted={isDone} />
))}
</div>
)}
<div className="card-title" id={titleId}>
{card.title}
</div>
<div className="card-meta">
<span className="card-person">
<Avatar memberId={card.assignee} size={22} />
<span className={`card-person-name${card.assignee ? "" : " is-unassigned"}`}>
{memberName(card.assignee)}
</span>
</span>
<span className="card-meta-right">
{total > 0 && (
<span className={`card-check${done === total ? " is-complete" : ""}`} title={`Checklist: ${done} of ${total} done`}>
<Icon.Checklist size={13} />
<span aria-hidden="true">
{done}/{total}
</span>
<VisuallyHidden>{`Checklist ${done} of ${total} done.`}</VisuallyHidden>
</span>
)}
{card.due ? (
<span
className={`card-due${overdue ? " is-overdue" : ""}${isDone ? " is-done" : ""}`}
title={`Due ${formatDue(card.due, true)}${overdue ? " (overdue)" : ""}`}
>
{overdue ? <Icon.Alert size={13} /> : <Icon.Calendar size={13} />}
<span>
{overdue && <span className="card-due-flag">Overdue · </span>}
{formatDue(card.due)}
</span>
<VisuallyHidden>{overdue ? " — due date has passed" : ", due date"}</VisuallyHidden>
</span>
) : (
<span className="card-due is-none" title="No due date">
<Icon.Calendar size={13} />
<span>No date</span>
</span>
)}
</span>
</div>
</>
);
}
/* ---------- Card ---------- */
interface CardItemProps {
card: Card;
column: Column;
columns: Column[];
index: number;
count: number;
lifted: boolean;
wipReason: (toColumnId: string) => string | null;
onOpen: (cardId: string) => void;
onMoveTo: (cardId: string, toColumnId: string) => void;
onDeleteRequest: (cardId: string) => void;
onPointerDown: (e: ReactPointerEvent<HTMLElement>, cardId: string, columnId: string) => void;
onKeyDown: (e: KeyboardEvent<HTMLElement>, cardId: string, columnId: string) => void;
suppressClick: React.MutableRefObject<boolean>;
}
export function CardItem({
card,
column,
columns,
index,
count,
lifted,
wipReason,
onOpen,
onMoveTo,
onDeleteRequest,
onPointerDown,
onKeyDown,
suppressClick,
}: CardItemProps) {
const titleId = `card-title-${card.id}`;
const overdue = isOverdue(card, column.id);
const moveItems = columns
.filter((c) => c.id !== column.id)
.map((c) => {
const reason = wipReason(c.id);
return {
id: c.id,
label: c.title,
hint: reason ? `Full · ${c.card_ids.length}/${c.wip_limit}` : undefined,
onSelect: () => onMoveTo(card.id, c.id),
};
});
return (
<article
id={`card-${card.id}`}
className={`card${lifted ? " is-lifted" : ""}${overdue ? " has-overdue" : ""}${column.id === "done" ? " is-done" : ""}`}
data-card-id={card.id}
tabIndex={0}
aria-labelledby={titleId}
aria-describedby={`card-hint-${card.id}`}
aria-roledescription="card"
onPointerDown={(e) => onPointerDown(e, card.id, column.id)}
onKeyDown={(e) => onKeyDown(e, card.id, column.id)}
onClick={(e) => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
if ((e.target as HTMLElement).closest("button, [role=menu]")) return;
onOpen(card.id);
}}
>
<VisuallyHidden id={`card-hint-${card.id}`}>
{`Position ${index + 1} of ${count} in ${column.title}. ${
lifted
? "Picked up. Use arrow keys to move, Space to drop, Escape to cancel."
: "Press Enter to open, Space to pick up and move with arrow keys."
}`}
</VisuallyHidden>
<div className="card-actions">
<Menu
label={`Move “${card.title}” to another column`}
icon={<Icon.Move size={14} />}
heading="Move to"
items={moveItems}
className="card-move"
/>
<Menu
label={`More actions for “${card.title}”`}
icon={<Icon.Dots size={14} />}
items={[
{ id: "open", label: "Open card", onSelect: () => onOpen(card.id) },
{ id: "delete", label: "Delete card…", danger: true, onSelect: () => onDeleteRequest(card.id) },
]}
/>
</div>
<CardContent card={card} columnId={column.id} titleId={titleId} />
</article>
);
}
/* ---------- Add-card form ---------- */
interface AddCardFormProps {
column: Column;
onSubmit: (title: string) => { ok: true } | { ok: false; reason: string };
onCancel: () => void;
}
export function AddCardForm({ column, onSubmit, onCancel }: AddCardFormProps) {
const [title, setTitle] = useState("");
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const errorId = `add-error-${column.id}`;
useEffect(() => {
inputRef.current?.focus();
}, []);
const submit = () => {
const res = onSubmit(title);
if (!res.ok) {
setError(res.reason);
inputRef.current?.focus();
return;
}
setTitle("");
setError(null);
};
return (
<form
className="add-form"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onCancel();
}
}}
>
<label className="sr-only" htmlFor={`add-title-${column.id}`}>
Title for the new card in {column.title}
</label>
<input
ref={inputRef}
id={`add-title-${column.id}`}
className={`add-input${error ? " is-invalid" : ""}`}
placeholder="What needs doing?"
value={title}
maxLength={140}
autoComplete="off"
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
onChange={(e) => {
setTitle(e.target.value);
if (error && e.target.value.trim()) setError(null);
}}
/>
{error && (
<p className="field-error" id={errorId} role="alert">
<Icon.Alert size={13} /> {error}
</p>
)}
<div className="add-actions">
<button type="submit" className="btn btn-primary btn-sm">
Add card
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={onCancel}>
Cancel
</button>
</div>
</form>
);
}