- 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 { useId, useRef, useState } from "react";
import type { BoardMeta, Card, Column } from "../types";
import { DONE_COLUMN_ID, isOverdue, makeId } from "../lib/board";
import { daysBetween, mediumDate } from "../lib/dates";
import { Modal } from "./Modal";
import { Avatar } from "./Brand";
import { PRIORITY_NAMES } from "./CardView";
import { IconAlert, IconCheckSquare, IconClose, IconPlus, IconText, IconTrash } from "./Icons";
interface CardDialogProps {
card: Card;
columnId: string;
columns: Column[];
meta: BoardMeta;
onChange: (card: Card) => void;
/** Returns an error message when the move was blocked. */
onMoveColumn: (columnId: string) => string | null;
onDelete: () => void;
onClose: () => void;
}
function dueSummary(due: string, today: string, overdue: boolean, done: boolean): string {
if (done) return "Completed";
const diff = daysBetween(today, due);
if (overdue) return diff === -1 ? "Overdue by 1 day" : `Overdue by ${-diff} days`;
if (diff === 0) return "Due today";
if (diff === 1) return "Due tomorrow";
return `Due in ${diff} days`;
}
export function CardDialog({ card, columnId, columns, meta, onChange, onMoveColumn, onDelete, onClose }: CardDialogProps) {
const [titleDraft, setTitleDraft] = useState(card.title);
const [titleError, setTitleError] = useState<string | null>(null);
const [moveError, setMoveError] = useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [newItem, setNewItem] = useState("");
const titleRef = useRef<HTMLTextAreaElement>(null);
const keepRef = useRef<HTMLButtonElement>(null);
const deleteBtnRef = useRef<HTMLButtonElement>(null);
const ids = {
heading: useId(),
title: useId(),
titleError: useId(),
desc: useId(),
column: useId(),
assignee: useId(),
priority: useId(),
due: useId(),
moveError: useId(),
newItem: useId(),
confirm: useId(),
};
const overdue = isOverdue(card, columnId, meta.today);
const done = columnId === DONE_COLUMN_ID;
const total = card.checklist.length;
const completed = card.checklist.filter((i) => i.done).length;
const member = meta.members.find((m) => m.id === card.assignee) ?? null;
function update(patch: Partial<Card>) {
onChange({ ...card, ...patch });
}
function addItem() {
const text = newItem.trim();
if (!text) return;
update({ checklist: [...card.checklist, { id: makeId(`${card.id}-i`), text, done: false }] });
setNewItem("");
}
function startDelete() {
setConfirmingDelete(true);
requestAnimationFrame(() => keepRef.current?.focus());
}
function cancelDelete() {
setConfirmingDelete(false);
requestAnimationFrame(() => deleteBtnRef.current?.focus());
}
return (
<Modal
labelledBy={ids.heading}
className="card-dialog"
initialFocus={titleRef}
onClose={onClose}
onEscape={confirmingDelete ? cancelDelete : onClose}
>
<div className="dialog-header">
<p className="dialog-eyebrow" id={ids.heading}>
Card details
<span className="sr-only">: {card.title}</span>
</p>
<button type="button" className="icon-btn" onClick={onClose} aria-label="Close card details">
<IconClose />
</button>
</div>
<div className="dialog-scroll">
<div className="field">
<label htmlFor={ids.title} className="sr-only">
Title
</label>
<textarea
ref={titleRef}
id={ids.title}
className="title-input"
rows={1}
value={titleDraft}
aria-invalid={titleError ? true : undefined}
aria-describedby={titleError ? ids.titleError : undefined}
onChange={(e) => {
const value = e.target.value.replace(/\n/g, " ");
setTitleDraft(value);
if (value.trim()) {
setTitleError(null);
update({ title: value.trim() });
} else {
setTitleError("A card needs a title. The previous title is kept until you type a new one.");
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.preventDefault();
}}
onBlur={() => {
if (titleDraft.trim() && titleDraft !== titleDraft.trim()) setTitleDraft(titleDraft.trim());
}}
/>
{titleError && (
<p id={ids.titleError} className="field-error" role="alert">
<IconAlert size={14} /> {titleError}
</p>
)}
</div>
<div className="dialog-grid">
<div className="dialog-main">
<section className="dialog-section" aria-labelledby={ids.desc}>
<h3 className="section-title">
<IconText size={14} />
<label id={ids.desc} htmlFor={`${ids.desc}-input`}>
Description
</label>
</h3>
<textarea
id={`${ids.desc}-input`}
className="textarea"
rows={4}
value={card.description}
placeholder="Add context, links, or acceptance notes…"
onChange={(e) => update({ description: e.target.value })}
/>
</section>
<section className="dialog-section" aria-label="Checklist">
<h3 className="section-title">
<IconCheckSquare size={14} />
Checklist
{total > 0 && (
<span className="section-meta">
{completed} of {total} done
</span>
)}
</h3>
{total > 0 && (
<div
className="progress"
role="progressbar"
aria-label="Checklist progress"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={completed}
aria-valuetext={`${completed} of ${total} done`}
>
<span style={{ width: `${(completed / total) * 100}%` }} />
</div>
)}
{total === 0 && <p className="muted-note">No checklist items yet.</p>}
<ul className="checklist">
{card.checklist.map((item) => (
<li key={item.id} className="checklist-item" data-done={item.done || undefined}>
<input
type="checkbox"
id={`chk-${item.id}`}
checked={item.done}
onChange={(e) =>
update({
checklist: card.checklist.map((i) => (i.id === item.id ? { ...i, done: e.target.checked } : i)),
})
}
/>
<label htmlFor={`chk-${item.id}`}>{item.text}</label>
<button
type="button"
className="icon-btn icon-btn-sm"
aria-label={`Remove checklist item “${item.text}”`}
onClick={() => update({ checklist: card.checklist.filter((i) => i.id !== item.id) })}
>
<IconClose size={14} />
</button>
</li>
))}
</ul>
<form
className="checklist-add"
onSubmit={(e) => {
e.preventDefault();
addItem();
}}
>
<label htmlFor={ids.newItem} className="sr-only">
New checklist item
</label>
<input
id={ids.newItem}
className="input"
placeholder="Add an item"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
/>
<button type="submit" className="btn btn-secondary btn-sm" disabled={!newItem.trim()}>
<IconPlus size={14} /> Add
</button>
</form>
</section>
</div>
<aside className="dialog-side" aria-label="Card properties">
<div className="field">
<label htmlFor={ids.column} className="field-label">
Column
</label>
<select
id={ids.column}
className="select"
value={columnId}
aria-describedby={moveError ? ids.moveError : undefined}
onChange={(e) => setMoveError(onMoveColumn(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>
{moveError && (
<p id={ids.moveError} className="field-error" role="alert">
{moveError}
</p>
)}
</div>
<div className="field">
<label htmlFor={ids.assignee} className="field-label">
Assignee
</label>
<div className="select-with-avatar">
<Avatar member={member} />
<select
id={ids.assignee}
className="select"
value={card.assignee ?? ""}
onChange={(e) => update({ assignee: e.target.value || null })}
>
<option value="">Unassigned</option>
{meta.members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
</div>
<div className="field">
<label htmlFor={ids.priority} className="field-label">
Priority
</label>
<select
id={ids.priority}
className="select"
value={card.priority}
onChange={(e) => update({ priority: e.target.value as Card["priority"] })}
>
{meta.priorities.map((p) => (
<option key={p} value={p}>
{PRIORITY_NAMES[p] ?? p}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor={ids.due} className="field-label">
Due date
</label>
<input
id={ids.due}
type="date"
className="input"
value={card.due ?? ""}
onChange={(e) => update({ due: e.target.value || null })}
/>
{card.due ? (
<p className="field-hint" data-tone={overdue ? "overdue" : undefined}>
{overdue && <IconAlert size={13} />}
{mediumDate(card.due)} · {dueSummary(card.due, meta.today, overdue, done)}
</p>
) : (
<p className="field-hint">No due date</p>
)}
</div>
<fieldset className="field label-picker">
<legend className="field-label">Labels</legend>
<div className="label-options">
{meta.labels.map((label) => {
const checked = card.labels.includes(label.id);
return (
<label key={label.id} className="label-option" data-label={label.id} data-checked={checked || undefined}>
<input
type="checkbox"
checked={checked}
onChange={(e) =>
update({
labels: e.target.checked
? meta.labels.map((l) => l.id).filter((id) => id === label.id || card.labels.includes(id))
: card.labels.filter((id) => id !== label.id),
})
}
/>
<span className="label-dot" aria-hidden="true" />
{label.name}
</label>
);
})}
</div>
</fieldset>
</aside>
</div>
</div>
<div className="dialog-footer">
{confirmingDelete ? (
<div className="confirm-row" role="group" aria-labelledby={ids.confirm}>
<p id={ids.confirm} className="confirm-text">
<strong>Delete this card?</strong> It leaves the board right away. You can still undo it afterwards.
</p>
<div className="confirm-actions">
<button ref={keepRef} type="button" className="btn btn-secondary" onClick={cancelDelete}>
Keep card
</button>
<button type="button" className="btn btn-danger" onClick={onDelete}>
<IconTrash size={14} /> Delete card
</button>
</div>
</div>
) : (
<>
<button ref={deleteBtnRef} type="button" className="btn btn-ghost btn-danger-text" onClick={startDelete}>
<IconTrash size={14} /> Delete…
</button>
<span className="footer-hint">Changes save automatically</span>
<button type="button" className="btn btn-primary" onClick={onClose}>
Done
</button>
</>
)}
</div>
</Modal>
);
}