- 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 { useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { BOARD_NAME, LABEL_STYLE, MEMBERS, TODAY, dueInfo, formatDueLong, formatDueShort, getLabel } from "../data";
import type { Card, ColumnState } from "../types";
import { focusElement, trapTabKey, useModalScrollLock } from "../focus";
import { IconAlert, IconCalendar, IconClose, IconTrash } from "../icons";
interface CardDialogProps {
card: Card;
columnId: string;
columnTitle: string;
columns: ColumnState[];
onPatch: (cardId: string, patch: Partial<Card>) => void;
onClose: (cardId: string) => void;
onRequestDelete: (cardId: string) => void;
onMove: (cardId: string, toColId: string) => void;
}
export function CardDialog(props: CardDialogProps) {
const { card, columnId, columnTitle, columns, onPatch, onClose, onRequestDelete, onMove } = props;
const [draftTitle, setDraftTitle] = useState(card.title);
const [titleError, setTitleError] = useState(false);
const panelRef = useRef<HTMLElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useModalScrollLock();
useEffect(() => {
setDraftTitle(card.title);
setTitleError(false);
focusElement(inputRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [card.id]);
const applyTitle = (raw: string) => {
setDraftTitle(raw);
const trimmed = raw.trim();
if (trimmed !== "") {
setTitleError(false);
if (trimmed !== card.title) onPatch(card.id, { title: trimmed });
}
};
const tryFinish = () => {
if (draftTitle.trim() === "") {
setTitleError(true);
focusElement(inputRef.current);
return;
}
onClose(card.id);
};
const onKeyDown = (e: ReactKeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
tryFinish();
return;
}
if (e.key === "Tab") trapTabKey(e, panelRef.current);
};
const due = dueInfo(card, columnId);
const doneCount = card.checklist.filter((i) => i.done).length;
const inputClasses = `dialog__title-input${titleError ? " dialog__title-input--invalid" : ""}`;
return (
<div
className="overlay"
onPointerDown={(e) => {
if (e.target === e.currentTarget) tryFinish();
}}
>
<section
ref={panelRef}
className="dialog"
role="dialog"
aria-modal="true"
aria-label={`Details for “${card.title}”`}
onKeyDown={onKeyDown}
>
<header className="dialog__head">
<div className="dialog__head-main">
<span className="dialog__crumb">
{BOARD_NAME} · {columnTitle}
</span>
<input
ref={inputRef}
id="card-dialog-title"
className={inputClasses}
value={draftTitle}
aria-label="Card title"
aria-invalid={titleError || undefined}
aria-describedby={titleError ? "card-title-error" : undefined}
spellCheck={false}
onChange={(e) => applyTitle(e.target.value)}
/>
{titleError && (
<p className="form-error" id="card-title-error" role="alert">
Title can’t be empty. Give the card a name to save it.
</p>
)}
</div>
<button type="button" className="btn btn--primary btn--sm dialog__done" onClick={tryFinish}>
Done
</button>
<button
type="button"
className="icon-btn dialog__close"
aria-label="Close card details"
onClick={() => onClose(card.id)}
>
<IconClose width={17} height={17} />
</button>
</header>
<div className="dialog__body">
<div className="dialog__main">
<label className="field-label" htmlFor="card-description">
Description
</label>
<textarea
id="card-description"
className="text-area"
value={card.description}
rows={5}
placeholder="Add context, links, or acceptance notes…"
onChange={(e) => onPatch(card.id, { description: e.target.value })}
/>
<div className="checklist-block">
<div className="checklist-head">
<span className="field-label">Checklist</span>
{card.checklist.length > 0 && (
<span className="checklist-progress">
{doneCount}/{card.checklist.length} done
</span>
)}
</div>
{card.checklist.length > 0 ? (
<ul className="checklist">
{card.checklist.map((item) => (
<li key={item.id} className={`check-item${item.done ? " check-item--done" : ""}`}>
<label className="check-item__label">
<input
type="checkbox"
checked={item.done}
onChange={() =>
onPatch(card.id, {
checklist: card.checklist.map((i) =>
i.id === item.id ? { ...i, done: !i.done } : i,
),
})
}
/>
<span>{item.text}</span>
</label>
</li>
))}
</ul>
) : (
<p className="muted-note">No checklist items on this card.</p>
)}
</div>
</div>
<aside className="dialog__side">
<div className="side-field">
<label className="field-label" htmlFor="dialog-column">
Column
</label>
<select
id="dialog-column"
className="select"
value={columnId}
onChange={(e) => onMove(card.id, e.target.value)}
>
{columns.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
{c.wip_limit != null ? ` (limit ${c.wip_limit})` : ""}
</option>
))}
</select>
</div>
<div className="side-field">
<label className="field-label" htmlFor="dialog-assignee">
Assignee
</label>
<select
id="dialog-assignee"
className="select"
value={card.assignee ?? ""}
onChange={(e) => onPatch(card.id, { assignee: e.target.value === "" ? null : e.target.value })}
>
<option value="">Unassigned</option>
{MEMBERS.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
<div className="side-field">
<span className="field-label">Due date</span>
<p className="side-value">
{card.due ? (
due?.tone === "overdue" ? (
<span className="due-flag due-flag--overdue">
<IconAlert width={13} height={13} /> Overdue · {formatDueShort(card.due)}
</span>
) : (
<span className="due-flag">
<IconCalendar width={13} height={13} />
{card.due === TODAY ? "Today" : formatDueLong(card.due)}
</span>
)
) : (
<span className="muted-note">No due date</span>
)}
</p>
</div>
<div className="side-field">
<span className="field-label">Priority</span>
<p className="side-value">
<span className={`prio-chip prio-chip--${card.priority}`}>{card.priority}</span>
</p>
</div>
<div className="side-field">
<span className="field-label">Labels</span>
<p className="side-value side-value--wrap">
{card.labels.length > 0 ? (
card.labels.map((lid) => {
const label = getLabel(lid);
if (!label) return null;
const style = LABEL_STYLE[lid];
return (
<span key={lid} className="tag" style={{ background: style?.bg, color: style?.fg }}>
{label.name}
</span>
);
})
) : (
<span className="muted-note">None</span>
)}
</p>
</div>
</aside>
</div>
<footer className="dialog__foot">
<button type="button" className="btn btn--danger-ghost" onClick={() => onRequestDelete(card.id)}>
<IconTrash width={14} height={14} />
Delete card…
</button>
</footer>
</section>
</div>
);
}