- 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 { Fragment, useState } from "react";
import type { FormEvent } from "react";
import type { Card, ColumnState } from "../types";
import { CardItem } from "./CardItem";
export interface DropPlaceholder {
index: number;
height: number;
blocked: boolean;
}
export interface AddResult {
ok: boolean;
error?: string;
}
interface ColumnViewProps {
column: ColumnState;
cards: Card[];
totalCount: number;
filtered: boolean;
dragOver: "ok" | "blocked" | null;
hiddenCardId: string | null;
kbdLiftedId: string | null;
openMenuCardId: string | null;
placeholder: DropPlaceholder | null;
onCardOpen: (id: string) => void;
onCardKeyDown: (e: React.KeyboardEvent<HTMLElement>, card: Card, columnId: string) => void;
onCardPointerDown: (e: React.PointerEvent<HTMLElement>, cardId: string, columnId: string) => void;
onMenuButton: (btn: HTMLButtonElement, cardId: string) => void;
onAdd: (colId: string, title: string) => AddResult;
}
function PlaceholderSlot(props: { placeholder: DropPlaceholder; wipLimit: number | null }) {
const { placeholder, wipLimit } = props;
return (
<li
className={`drop-slot${placeholder.blocked ? " drop-slot--blocked" : ""}`}
style={{ height: placeholder.height }}
aria-hidden="true"
>
{placeholder.blocked && <span className="drop-slot__note">Full · limit {wipLimit}</span>}
</li>
);
}
export function ColumnView(props: ColumnViewProps) {
const {
column,
cards,
totalCount,
filtered,
dragOver,
hiddenCardId,
kbdLiftedId,
openMenuCardId,
placeholder,
onCardOpen,
onCardKeyDown,
onCardPointerDown,
onMenuButton,
onAdd,
} = props;
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const [limitNote, setLimitNote] = useState<string | null>(null);
const atLimit = column.wip_limit != null && totalCount >= column.wip_limit;
const countText =
column.wip_limit != null
? `${cards.length}/${column.wip_limit}`
: filtered
? `${cards.length} of ${totalCount}`
: `${totalCount}`;
const countAria =
column.wip_limit != null
? `${cards.length} of ${column.wip_limit} allowed`
: `${cards.length} cards`;
const columnClasses = [
"column",
dragOver === "ok" ? "column--over" : "",
dragOver === "blocked" ? "column--over-blocked" : "",
]
.filter(Boolean)
.join(" ");
const startAdd = () => {
setError(null);
if (atLimit) {
setLimitNote(
`“${column.title}” is at its limit of ${column.wip_limit} cards. Finish or move one before adding another.`,
);
return;
}
setLimitNote(null);
setAdding(true);
};
const cancelAdd = () => {
setAdding(false);
setDraft("");
setError(null);
};
const submitAdd = (e: FormEvent) => {
e.preventDefault();
const result = onAdd(column.id, draft);
if (!result.ok) {
setError(result.error ?? "Something went wrong. Try again.");
return;
}
cancelAdd();
};
const placeholderAt = (index: number) =>
placeholder !== null && placeholder.height > 0 && placeholder.index === index;
return (
<section className={columnClasses} data-column-id={column.id} aria-label={`${column.title}, ${countAria}`}>
<header className="column__head">
<span className="column__dot" data-col={column.id} aria-hidden="true" />
<h2 className="column__title">{column.title}</h2>
{column.wip_limit != null && (
<span
className="column__limit"
title={`Work in progress limit: ${column.wip_limit} cards`}
>
WIP
</span>
)}
<span className={`column__count${atLimit ? " column__count--full" : ""}`}>{countText}</span>
</header>
<ul className="cards">
{placeholderAt(0) && placeholder && (
<PlaceholderSlot placeholder={placeholder} wipLimit={column.wip_limit} />
)}
{cards.map((card, i) => (
<Fragment key={card.id}>
<CardItem
card={card}
columnId={column.id}
hidden={hiddenCardId === card.id}
kbdLifted={kbdLiftedId === card.id}
menuOpen={openMenuCardId === card.id}
onOpen={() => onCardOpen(card.id)}
onKeyDown={onCardKeyDown}
onPointerDown={onCardPointerDown}
onMenuButton={onMenuButton}
/>
{placeholderAt(i + 1) && placeholder && (
<PlaceholderSlot placeholder={placeholder} wipLimit={column.wip_limit} />
)}
</Fragment>
))}
{cards.length === 0 && !placeholder && (
<li className="cards__empty">{filtered ? "No matching cards" : "No cards yet"}</li>
)}
</ul>
<div className="column__foot">
{adding ? (
<form className="add-form" onSubmit={submitAdd} noValidate>
<input
autoFocus
type="text"
className="text-input"
aria-label={`New card title for ${column.title}`}
placeholder="What needs doing?"
value={draft}
onChange={(e) => {
setDraft(e.target.value);
if (e.target.value.trim() !== "") setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
cancelAdd();
}
}}
/>
{error && (
<p className="form-error" role="alert">
{error}
</p>
)}
<div className="add-form__row">
<button type="submit" className="btn btn--primary btn--sm">
Add card
</button>
<button type="button" className="btn btn--ghost btn--sm" onClick={cancelAdd}>
Cancel
</button>
</div>
</form>
) : (
<>
{limitNote && (
<p className="form-note" role="alert">
{limitNote}
</p>
)}
<button
type="button"
className="add-btn"
data-addbtn={column.id}
onClick={startAdd}
aria-label={`Add a card to ${column.title}`}
>
<span className="add-btn__plus" aria-hidden="true">
+
</span>{" "}
Add card
</button>
</>
)}
</div>
</section>
);
}