- 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 { Fragment, useEffect, useId, useRef, useState } from "react";
import type { BoardMeta, Card, Column } from "../types";
import { CardView } from "./CardView";
import { IconLock, IconPlus } from "./Icons";
export interface DragView {
cardId: string;
overColumnId: string | null;
beforeId: string | null;
blocked: boolean;
height: number;
}
interface ColumnViewProps {
column: Column;
visibleIds: string[];
cards: Record<string, Card>;
meta: BoardMeta;
filtered: boolean;
drag: DragView | null;
pickedId: string | null;
menuCardId: string | null;
onOpen: (cardId: string) => void;
onCardPointerDown: (e: React.PointerEvent<HTMLElement>, cardId: string) => void;
onCardKeyDown: (e: React.KeyboardEvent<HTMLElement>, cardId: string) => void;
onCardBlur: (e: React.FocusEvent<HTMLElement>, cardId: string) => void;
onMoveClick: (anchor: HTMLElement, cardId: string) => void;
/** Returns an error message when the card could not be created. */
onCreate: (columnId: string, title: string) => string | null;
}
export function ColumnView({
column,
visibleIds,
cards,
meta,
filtered,
drag,
pickedId,
menuCardId,
onOpen,
onCardPointerDown,
onCardKeyDown,
onCardBlur,
onMoveClick,
onCreate,
}: ColumnViewProps) {
const headingId = useId();
const total = column.card_ids.length;
const limit = column.wip_limit;
const atLimit = limit != null && total >= limit;
const isTarget = drag?.overColumnId === column.id;
const showPlaceholder = isTarget && !drag!.blocked;
const dropState = isTarget ? (drag!.blocked ? "blocked" : "over") : undefined;
const rendered = visibleIds.filter((id) => !(drag && id === drag.cardId && drag.overColumnId !== null));
const visibleCount = visibleIds.length;
const placeholder = showPlaceholder ? (
<li className="drop-placeholder" style={{ height: drag!.height }} aria-hidden="true" />
) : null;
return (
<section
className="column"
data-column-id={column.id}
data-drop-state={dropState}
data-at-limit={atLimit || undefined}
aria-labelledby={headingId}
>
<header className="column-header">
<h2 id={headingId} className="column-title" tabIndex={-1}>
{column.title}
</h2>
<span className="column-count" data-column-count>
{visibleCount}
<span className="sr-only">
{filtered ? ` of ${total} cards shown` : visibleCount === 1 ? " card" : " cards"}
</span>
</span>
{limit != null && (
<span className="wip-chip" data-full={atLimit || undefined} title={`Work-in-progress limit: ${limit} cards`}>
{atLimit && <IconLock size={12} />}
<span>
{atLimit ? "Full" : "Limit"} · {total}/{limit}
</span>
<span className="sr-only">
{" "}
— work-in-progress limit of {limit} cards{atLimit ? ", column is full" : ""}
</span>
</span>
)}
</header>
<div className="column-body" data-column-scroll>
{dropState === "blocked" && (
<p className="drop-blocked-note" role="presentation">
<IconLock size={14} />
{column.title} is full ({limit} of {limit}). Drop elsewhere.
</p>
)}
<ul className="card-list" data-column-list aria-label={`${column.title} cards`}>
{rendered.map((id) => {
const card = cards[id];
if (!card) return null;
return (
<Fragment key={id}>
{showPlaceholder && drag!.beforeId === id && placeholder}
<CardView
card={card}
columnId={column.id}
meta={meta}
picked={pickedId === id}
menuOpen={menuCardId === id}
onOpen={onOpen}
onPointerDown={onCardPointerDown}
onKeyDown={onCardKeyDown}
onBlur={onCardBlur}
onMoveClick={onMoveClick}
/>
</Fragment>
);
})}
{showPlaceholder && drag!.beforeId === null && placeholder}
</ul>
{rendered.length === 0 && !showPlaceholder && (
<p className="column-empty">
{filtered && total > 0 ? "No cards here match your filters." : "Nothing here yet. Drag a card in or add one below."}
</p>
)}
</div>
<Composer column={column} atLimit={atLimit} onCreate={onCreate} />
</section>
);
}
function Composer({
column,
atLimit,
onCreate,
}: {
column: Column;
atLimit: boolean;
onCreate: (columnId: string, title: string) => string | null;
}) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const openerRef = useRef<HTMLButtonElement>(null);
const errorId = useId();
const inputId = useId();
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
function close() {
setOpen(false);
setTitle("");
setError(null);
requestAnimationFrame(() => openerRef.current?.focus());
}
function submit() {
if (!title.trim()) {
setError("Give the card a title before adding it.");
inputRef.current?.focus();
return;
}
const problem = onCreate(column.id, title.trim());
if (problem) {
setError(problem);
return;
}
setTitle("");
setError(null);
inputRef.current?.focus();
}
if (!open) {
return (
<div className="column-footer">
<button
ref={openerRef}
type="button"
className="add-card-btn"
onClick={() => setOpen(true)}
aria-label={`Add a card to ${column.title}`}
>
<IconPlus size={14} />
Add card
</button>
</div>
);
}
return (
<form
className="composer"
noValidate
onSubmit={(e) => {
e.preventDefault();
submit();
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
close();
}
}}
>
<label className="sr-only" htmlFor={inputId}>
New card title for {column.title}
</label>
{atLimit && !error && (
<p className="composer-note">
<IconLock size={12} /> {column.title} is at its limit of {column.wip_limit}. A new card can’t go here until one
moves on.
</p>
)}
<textarea
ref={inputRef}
id={inputId}
className="composer-input"
rows={2}
placeholder="What needs doing?"
value={title}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
onChange={(e) => {
setTitle(e.target.value);
if (error && e.target.value.trim()) setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
/>
{error && (
<p id={errorId} className="field-error" role="alert">
{error}
</p>
)}
<div className="composer-actions">
<button type="submit" className="btn btn-primary btn-sm">
Add card
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={close}>
Cancel
</button>
</div>
</form>
);
}