- 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CardData, ColumnData, LabelDef, Member } from "../types";
import { Avatar } from "./Avatar";
import { labelStyle } from "../labelStyles";
import { IconClose, IconPlus, IconTrash } from "../icons";
import { priorityLabel } from "../utils";
export interface CardDialogProps {
card: CardData;
columnId: string;
columns: ColumnData[];
members: Member[];
labels: LabelDef[];
isColumnFull: (columnId: string) => boolean;
onChange: (patch: Partial<CardData>) => void;
onMoveColumn: (columnId: string) => string | null;
onDelete: () => void;
onClose: () => void;
}
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function CardDialog({
card,
columnId,
columns,
members,
labels,
isColumnFull,
onChange,
onMoveColumn,
onDelete,
onClose,
}: CardDialogProps) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const titleRef = useRef<HTMLInputElement | null>(null);
const [titleDraft, setTitleDraft] = useState(card.title);
const [titleError, setTitleError] = useState<string | null>(null);
const [columnError, setColumnError] = useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [newItemText, setNewItemText] = useState("");
useEffect(() => {
setTitleDraft(card.title);
setTitleError(null);
setColumnError(null);
setConfirmingDelete(false);
setNewItemText("");
}, [card.id]);
useEffect(() => {
const input = titleRef.current;
if (!input) return;
input.focus();
input.select();
}, [card.id]);
const close = useCallback(() => {
onClose();
}, [onClose]);
useEffect(() => {
const node = dialogRef.current;
if (!node) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close();
return;
}
if (event.key !== "Tab") return;
const focusables = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.tabIndex !== -1,
);
if (focusables.length === 0) {
event.preventDefault();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (event.shiftKey) {
if (!active || active === first || !node.contains(active)) {
event.preventDefault();
last.focus();
}
} else if (!active || active === last || !node.contains(active)) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [close]);
const column = useMemo(
() => columns.find((item) => item.id === columnId) ?? columns[0],
[columns, columnId],
);
const commitTitle = (value: string) => {
setTitleDraft(value);
if (value.trim() === "") {
setTitleError("A card needs a title. Add one before saving.");
return false;
}
setTitleError(null);
if (value.trim() !== card.title) onChange({ title: value.trim() });
return true;
};
const handleSave = () => {
if (!commitTitle(titleDraft)) {
titleRef.current?.focus();
return;
}
close();
};
const handleColumnChange = (nextColumnId: string) => {
const error = onMoveColumn(nextColumnId);
setColumnError(error);
};
const addChecklistItem = () => {
const text = newItemText.trim();
if (!text) return;
onChange({
checklist: [...card.checklist, { id: `item-${Date.now().toString(36)}-${card.checklist.length}`, text, done: false }],
});
setNewItemText("");
};
return (
<div className="modal-layer">
<div
className="modal-backdrop"
onPointerDown={(event) => {
if (event.target === event.currentTarget) close();
}}
/>
<div
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby="card-dialog-heading"
ref={dialogRef}
>
<header className="modal-head">
<div className="modal-head-text">
<h2 id="card-dialog-heading">Card details</h2>
<p className="modal-sub">
In <span className="modal-column-name">{column ? column.title : "—"}</span>
<span className="modal-card-id"> · {card.id}</span>
</p>
</div>
<button type="button" className="icon-btn" aria-label="Close card details" onClick={close}>
<IconClose size={17} />
</button>
</header>
<div className="modal-body">
<div className="field">
<label className="field-label" htmlFor="card-title-input">
Title <span className="field-required">required</span>
</label>
<input
id="card-title-input"
ref={titleRef}
className={`text-input ${titleError ? "has-error" : ""}`}
value={titleDraft}
aria-invalid={titleError ? true : undefined}
aria-describedby={titleError ? "card-title-error" : undefined}
onChange={(event) => commitTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleSave();
}
}}
/>
{titleError && (
<p className="field-error" id="card-title-error" role="alert">
{titleError}
</p>
)}
</div>
<div className="field">
<label className="field-label" htmlFor="card-description-input">
Description
</label>
<textarea
id="card-description-input"
className="text-input textarea"
rows={3}
placeholder="Add context, links, or acceptance notes…"
value={card.description}
onChange={(event) => onChange({ description: event.target.value })}
/>
</div>
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="card-assignee-select">
Assignee
</label>
<div className="select-wrap">
<select
id="card-assignee-select"
className="select-input"
value={card.assignee ?? ""}
onChange={(event) => onChange({ assignee: event.target.value || null })}
>
<option value="">Unassigned</option>
{members.map((member) => (
<option key={member.id} value={member.id}>
{member.name} — {member.role}
</option>
))}
</select>
</div>
</div>
<div className="field">
<label className="field-label" htmlFor="card-column-select">
Column
</label>
<div className="select-wrap">
<select
id="card-column-select"
className="select-input"
value={columnId}
aria-describedby={columnError ? "card-column-error" : undefined}
onChange={(event) => handleColumnChange(event.target.value)}
>
{columns.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
{item.wip_limit !== null ? ` (limit ${item.wip_limit})` : ""}
{isColumnFull(item.id) && item.id !== columnId ? " — full" : ""}
</option>
))}
</select>
</div>
{columnError && (
<p className="field-error" id="card-column-error" role="alert">
{columnError}
</p>
)}
</div>
</div>
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="card-priority-select">
Priority
</label>
<div className="select-wrap">
<select
id="card-priority-select"
className="select-input"
value={card.priority}
onChange={(event) => onChange({ priority: event.target.value as CardData["priority"] })}
>
{["low", "medium", "high", "urgent"].map((priority) => (
<option key={priority} value={priority}>
{priorityLabel(priority as CardData["priority"])}
</option>
))}
</select>
</div>
</div>
<div className="field">
<label className="field-label" htmlFor="card-due-input">
Due date
</label>
<div className="due-field">
<input
id="card-due-input"
type="date"
className="text-input"
value={card.due ?? ""}
onChange={(event) => onChange({ due: event.target.value || null })}
/>
{card.due && (
<button
type="button"
className="btn ghost small"
onClick={() => onChange({ due: null })}
>
Clear
</button>
)}
</div>
</div>
</div>
<fieldset className="field labels-field">
<legend className="field-label">Labels</legend>
<div className="label-toggle-row">
{labels.map((label, index) => {
const color = labelStyle(label.id, index);
const active = card.labels.includes(label.id);
return (
<button
key={label.id}
type="button"
className={`label-toggle ${active ? "is-active" : ""}`}
style={active ? { background: color.bg, color: color.fg, borderColor: color.border } : undefined}
aria-pressed={active}
onClick={() =>
onChange({
labels: active
? card.labels.filter((id) => id !== label.id)
: [...card.labels, label.id],
})
}
>
{label.name}
</button>
);
})}
</div>
</fieldset>
<fieldset className="field checklist-field">
<legend className="field-label">
Checklist
{card.checklist.length > 0 && (
<span className="field-meta">
{card.checklist.filter((item) => item.done).length}/{card.checklist.length} done
</span>
)}
</legend>
{card.checklist.length === 0 ? (
<p className="field-hint">No checklist items yet. Add one below.</p>
) : (
<ul className="checklist">
{card.checklist.map((item) => (
<li key={item.id} className={`checklist-item ${item.done ? "is-done" : ""}`}>
<input
type="checkbox"
className="checklist-check"
checked={item.done}
aria-label={`Mark “${item.text}” as ${item.done ? "not done" : "done"}`}
onChange={() =>
onChange({
checklist: card.checklist.map((entry) =>
entry.id === item.id ? { ...entry, done: !entry.done } : entry,
),
})
}
/>
<input
className="checklist-text"
value={item.text}
aria-label="Checklist item text"
onChange={(event) =>
onChange({
checklist: card.checklist.map((entry) =>
entry.id === item.id ? { ...entry, text: event.target.value } : entry,
),
})
}
/>
<button
type="button"
className="icon-btn small"
aria-label={`Remove checklist item “${item.text}”`}
onClick={() =>
onChange({
checklist: card.checklist.filter((entry) => entry.id !== item.id),
})
}
>
<IconClose size={14} />
</button>
</li>
))}
</ul>
)}
<div className="checklist-add">
<input
className="text-input"
placeholder="Add a checklist item…"
value={newItemText}
aria-label="New checklist item"
onChange={(event) => setNewItemText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addChecklistItem();
}
}}
/>
<button
type="button"
className="btn ghost small"
disabled={newItemText.trim() === ""}
onClick={addChecklistItem}
>
<IconPlus size={14} />
Add
</button>
</div>
</fieldset>
<div className="field">
<span className="field-label">Assigned to</span>
<div className="assignee-preview">
<Avatar
member={card.assignee ? members.find((member) => member.id === card.assignee) ?? null : null}
size={26}
/>
<span>
{card.assignee
? members.find((member) => member.id === card.assignee)?.name ?? "Unassigned"
: "Nobody yet — choose an assignee above."}
</span>
</div>
</div>
</div>
<footer className="modal-foot">
<div className="modal-foot-left">
{confirmingDelete ? (
<div className="delete-confirm" role="alert">
<span>
Delete “{card.title}”? You can undo this from the board.
</span>
<div className="delete-confirm-actions">
<button
type="button"
className="btn danger small"
onClick={() => {
setConfirmingDelete(false);
onDelete();
}}
>
Delete card
</button>
<button
type="button"
className="btn ghost small"
onClick={() => setConfirmingDelete(false)}
>
Keep card
</button>
</div>
</div>
) : (
<button
type="button"
className="btn danger-ghost small"
onClick={() => setConfirmingDelete(true)}
>
<IconTrash size={14} />
Delete card
</button>
)}
</div>
<div className="modal-foot-right">
<span className="modal-save-hint">Changes save as you edit.</span>
<button type="button" className="btn ghost" onClick={close}>
Close
</button>
<button type="button" className="btn primary" onClick={handleSave}>
Save changes
</button>
</div>
</footer>
</div>
</div>
);
}