- 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 RKeyboardEvent } from "react";
import {
LABELS,
LABEL_STYLES,
MEMBERS,
PRIORITIES,
PRIORITY_META,
formatDueLong,
isOverdue,
type Card,
} from "../model";
import { IconAlert, IconCalendar, IconClose, IconTrash } from "./Icons";
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
type Props = {
card: Card;
colId: string;
colTitle: string;
onClose: () => void;
onDelete: () => void;
onPatch: (patch: Partial<Card>) => void;
};
export function CardDialog({ card, colId, colTitle, onClose, onDelete, onPatch }: Props) {
const [draftTitle, setDraftTitle] = useState(card.title);
const [titleError, setTitleError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [newItem, setNewItem] = useState("");
const dialogRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
titleRef.current?.focus();
}, []);
useEffect(() => {
document.body.classList.add("modal-open");
return () => document.body.classList.remove("modal-open");
}, []);
const overdue = isOverdue(card, colId);
const done = card.checklist.filter(i => i.done).length;
const total = card.checklist.length;
function patchTitle(v: string) {
setDraftTitle(v);
if (v.trim()) {
setTitleError(null);
onPatch({ title: v.trim() });
} else {
setTitleError("A card needs a title. Add one before closing.");
}
}
function doneEditing() {
if (!draftTitle.trim()) {
setTitleError("A card needs a title. Add one before closing.");
titleRef.current?.focus();
return;
}
onPatch({ title: draftTitle.trim() });
onClose();
}
function handleKey(e: RKeyboardEvent<HTMLDivElement>) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
if (e.key === "Tab") trapTab(e);
}
function trapTab(e: RKeyboardEvent<HTMLDivElement>) {
const container = dialogRef.current;
if (!container) return;
const items = Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
el => !el.hasAttribute("disabled") && el.offsetParent !== null,
);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || !container.contains(active))) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || !container.contains(active))) {
e.preventDefault();
first.focus();
}
}
function toggleItem(id: string) {
onPatch({ checklist: card.checklist.map(i => (i.id === id ? { ...i, done: !i.done } : i)) });
}
function editItem(id: string, text: string) {
onPatch({ checklist: card.checklist.map(i => (i.id === id ? { ...i, text } : i)) });
}
function removeItem(id: string) {
onPatch({ checklist: card.checklist.filter(i => i.id !== id) });
}
function addItem() {
const text = newItem.trim();
if (!text) return;
onPatch({ checklist: [...card.checklist, { id: `${card.id}-${Date.now().toString(36)}`, text, done: false }] });
setNewItem("");
}
function toggleLabel(id: string) {
const has = card.labels.includes(id);
onPatch({ labels: has ? card.labels.filter(l => l !== id) : [...card.labels, id] });
}
return (
<div
className="dialog-overlay"
onPointerDown={e => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
className="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="card-dialog-title"
onKeyDown={handleKey}
>
<header className="dialog-head">
<div className="dialog-head-text">
<h2 id="card-dialog-title" className="dialog-title">
{card.title || "Untitled card"}
</h2>
<p className="dialog-sub">
<span className="badge">{colTitle}</span>
<span className="badge gray">{card.id}</span>
{overdue && <span className="badge danger">Overdue</span>}
</p>
</div>
<button type="button" className="icon-btn" aria-label="Close card details" onClick={onClose}>
<IconClose size={16} />
</button>
</header>
<div className="dialog-body">
<div className="field">
<label htmlFor="dlg-title" className="field-label">
Title
</label>
<input id="dlg-title" ref={titleRef} className="text-input" value={draftTitle} onChange={e => patchTitle(e.target.value)} />
{titleError != null && (
<p className="field-error" role="alert">
{titleError}
</p>
)}
</div>
<div className="field">
<label htmlFor="dlg-desc" className="field-label">
Description
</label>
<textarea
id="dlg-desc"
rows={4}
className="text-input"
value={card.description}
onChange={e => onPatch({ description: e.target.value })}
placeholder="Add more detail…"
/>
</div>
<div className="field-grid">
<div className="field">
<label htmlFor="dlg-assignee" className="field-label">
Assignee
</label>
<select
id="dlg-assignee"
className="text-input"
value={card.assignee ?? "none"}
onChange={e => onPatch({ assignee: e.target.value === "none" ? null : e.target.value })}
>
<option value="none">Unassigned</option>
{MEMBERS.map(m => (
<option key={m.id} value={m.id}>
{m.name} — {m.role}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="dlg-priority" className="field-label">
Priority
</label>
<select id="dlg-priority" className="text-input" value={card.priority} onChange={e => onPatch({ priority: e.target.value })}>
{PRIORITIES.map(p => (
<option key={p} value={p}>
{PRIORITY_META[p]?.label ?? p}
</option>
))}
</select>
</div>
</div>
<div className="field">
<label htmlFor="dlg-due" className="field-label">
Due date
</label>
<div className="due-row">
<input
id="dlg-due"
type="date"
className="text-input"
style={{ width: "auto" }}
value={card.due ?? ""}
onChange={e => onPatch({ due: e.target.value || null })}
/>
{card.due && (
<button type="button" className="btn subtle small" onClick={() => onPatch({ due: null })}>
Clear
</button>
)}
{card.due && (
<span className={`due-hint${overdue ? " overdue" : ""}`}>
<IconCalendar size={13} />
{overdue ? `Overdue · ${formatDueLong(card.due)}` : `Due ${formatDueLong(card.due)}`}
</span>
)}
</div>
</div>
<div className="field">
<span className="field-label">Labels</span>
<div className="label-toggles">
{LABELS.map(l => {
const on = card.labels.includes(l.id);
const st = LABEL_STYLES[l.id];
return (
<button
key={l.id}
type="button"
className={`label-toggle${on ? " on" : ""}`}
aria-pressed={on}
style={on && st ? { background: st.bg, color: st.fg, borderColor: st.bd } : undefined}
onClick={() => toggleLabel(l.id)}
>
{l.name}
</button>
);
})}
</div>
</div>
<div className="field">
<span className="field-label">Checklist{total > 0 ? ` — ${done}/${total} done` : ""}</span>
{total > 0 && (
<div
className="check-progress"
role="progressbar"
aria-valuenow={done}
aria-valuemin={0}
aria-valuemax={total}
aria-label="Checklist progress"
>
<div className="check-progress-fill" style={{ width: `${(done / total) * 100}%` }} />
</div>
)}
<ul className="check-list">
{card.checklist.map(item => (
<li key={item.id} className={`check-item${item.done ? " done" : ""}`}>
<input
type="checkbox"
checked={item.done}
onChange={() => toggleItem(item.id)}
aria-label={`Mark “${item.text}” ${item.done ? "not done" : "done"}`}
/>
<input
className="check-text"
value={item.text}
onChange={e => editItem(item.id, e.target.value)}
aria-label={`Checklist item text: ${item.text}`}
/>
<button
type="button"
className="icon-btn small"
aria-label={`Remove checklist item “${item.text}”`}
onClick={() => removeItem(item.id)}
>
<IconClose size={13} />
</button>
</li>
))}
</ul>
<div className="check-add">
<input
className="text-input"
placeholder="Add an item…"
value={newItem}
onChange={e => setNewItem(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
addItem();
}
}}
aria-label="New checklist item"
/>
<button type="button" className="btn subtle" onClick={addItem}>
Add item
</button>
</div>
</div>
</div>
<footer className="dialog-foot">
{confirmDelete ? (
<>
<div className="confirm-box">
<p>
<IconAlert size={14} />
Delete “{card.title}”? It will be removed from {colTitle}. You can undo this right after.
</p>
<div className="confirm-row">
<button type="button" className="btn subtle" onClick={() => setConfirmDelete(false)}>
Keep card
</button>
<button type="button" className="btn danger" onClick={onDelete}>
Delete card
</button>
</div>
</div>
<span className="flex-spacer" />
<button type="button" className="btn primary" onClick={doneEditing}>
Done
</button>
</>
) : (
<>
<button type="button" className="btn danger-ghost" onClick={() => setConfirmDelete(true)}>
<IconTrash size={14} />
Delete card…
</button>
<span className="flex-spacer" />
<span className="autosave-hint">Changes save as you edit</span>
<button type="button" className="btn primary" onClick={doneEditing}>
Done
</button>
</>
)}
</footer>
</div>
</div>
);
}