- 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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { BoardMeta, BoardState, Card, Filters, HistoryEntry, Location } from "./types";
import {
getColumn,
hasActiveFilters,
indexBefore,
insertCard,
isOverdue,
locate,
makeId,
matchesFilters,
moveCard,
removeCard,
updateCard,
wouldExceedLimit,
} from "./lib/board";
import { clearSaved, loadData, readSaved, save, type LoadedData } from "./lib/data";
import { mediumDate } from "./lib/dates";
import { usePointerDrag } from "./lib/usePointerDrag";
import { Avatar, Wordmark } from "./components/Brand";
import { ColumnView } from "./components/ColumnView";
import { CardDialog } from "./components/CardDialog";
import { MoveMenu } from "./components/MoveMenu";
import { Modal } from "./components/Modal";
import { IconAlert, IconClose, IconFilterOff, IconReset, IconSearch, IconUndo } from "./components/Icons";
const FICTIONAL_NOTICE = "Boardly is a fictional product created for UIBenchmark.";
const EMPTY_FILTERS: Filters = { assignee: "all", label: "all", query: "" };
function cloneBoard(board: BoardState): BoardState {
return JSON.parse(JSON.stringify(board)) as BoardState;
}
const DATA: LoadedData = loadData();
export function App() {
const [board, setBoard] = useState<BoardState | null>(() => readSaved(DATA.initial) ?? cloneBoard(DATA.initial));
useEffect(() => {
if (board) save(board);
}, [board]);
if (!board) return null;
return <BoardApp meta={DATA.meta} initial={DATA.initial} board={board} setBoard={setBoard} />;
}
interface Toast {
id: number;
message: string;
tone: "info" | "warn";
undo: boolean;
}
interface KeyboardDrag {
cardId: string;
origin: Location;
}
function isTextField(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
if (el.isContentEditable || el instanceof HTMLTextAreaElement) return true;
if (el instanceof HTMLInputElement) {
return !["checkbox", "radio", "button", "submit", "reset", "range", "color", "file"].includes(el.type);
}
return false;
}
function focusCard(cardId: string, fallbackColumnId?: string) {
requestAnimationFrame(() => {
const el = document.querySelector<HTMLElement>(`[data-card-id="${cardId}"]`);
if (el) {
el.focus();
el.scrollIntoView({ block: "nearest", inline: "nearest" });
return;
}
if (fallbackColumnId) {
document.querySelector<HTMLElement>(`[data-column-id="${fallbackColumnId}"] .column-title`)?.focus();
}
});
}
function BoardApp({
meta,
initial,
board,
setBoard,
}: {
meta: BoardMeta;
initial: BoardState;
board: BoardState;
setBoard: React.Dispatch<React.SetStateAction<BoardState | null>>;
}) {
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [openCardId, setOpenCardId] = useState<string | null>(null);
const [moveMenu, setMoveMenu] = useState<{ cardId: string; anchor: HTMLElement } | null>(null);
const [toast, setToast] = useState<Toast | null>(null);
const [live, setLive] = useState("");
const [kbd, setKbd] = useState<KeyboardDrag | null>(null);
const [confirmReset, setConfirmReset] = useState(false);
const boardRef = useRef<HTMLDivElement>(null);
const resetBtnRef = useRef<HTMLButtonElement>(null);
const toastSeq = useRef(0);
const setBoardState = useCallback((next: BoardState | ((b: BoardState) => BoardState)) => {
setBoard((prev) => (prev ? (typeof next === "function" ? next(prev) : next) : prev));
}, [setBoard]);
// ----- Derived ----------------------------------------------------------
const filtered = hasActiveFilters(filters);
const visibleByColumn = useMemo(() => {
const out: Record<string, string[]> = {};
for (const column of board.columns) {
out[column.id] = column.card_ids.filter((id) => board.cards[id] && matchesFilters(board.cards[id], filters));
}
return out;
}, [board, filters]);
const totalCards = board.columns.reduce((n, c) => n + c.card_ids.length, 0);
const visibleCards = Object.values(visibleByColumn).reduce((n, ids) => n + ids.length, 0);
const overdueCount = board.columns.reduce(
(n, c) => n + c.card_ids.filter((id) => board.cards[id] && isOverdue(board.cards[id], c.id, meta.today)).length,
0,
);
// ----- Feedback ---------------------------------------------------------
const announce = useCallback((message: string) => {
// Alternate a trailing no-break space so identical messages are re-announced.
setLive((prev) => (prev === message ? `${message} ` : message));
}, []);
const showToast = useCallback((message: string, tone: Toast["tone"] = "info", undo = false) => {
toastSeq.current += 1;
setToast({ id: toastSeq.current, message, tone, undo });
}, []);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast((cur) => (cur?.id === toast.id ? null : cur)), toast.tone === "warn" ? 9000 : 6000);
return () => window.clearTimeout(t);
}, [toast]);
function columnTitle(columnId: string) {
return getColumn(board, columnId)?.title ?? columnId;
}
function limitMessage(columnId: string, newCard = false) {
const column = getColumn(board, columnId)!;
const outcome = newCard ? "so a new card can’t be added" : "so this card stays where it was";
return `${column.title} is full: it holds at most ${column.wip_limit} cards at a time, ${outcome}. Finish or move a card out of ${column.title} first.`;
}
function positionText(b: BoardState, cardId: string) {
const loc = locate(b, cardId)!;
const column = getColumn(b, loc.columnId)!;
return `${column.title}, position ${loc.index + 1} of ${column.card_ids.length}`;
}
function pushHistory(entry: HistoryEntry) {
setHistory((h) => [...h.slice(-49), entry]);
}
// ----- Moves ------------------------------------------------------------
/** Moves a card and records it for undo. Returns an explanation when blocked. */
function commitMove(cardId: string, toColumnId: string, toIndex: number, quiet = false): string | null {
const from = locate(board, cardId);
const card = board.cards[cardId];
if (!from || !card) return "That card no longer exists.";
if (toColumnId !== from.columnId && wouldExceedLimit(board, toColumnId, cardId)) {
const msg = limitMessage(toColumnId);
showToast(msg, "warn");
announce(msg);
return msg;
}
const next = moveCard(board, cardId, toColumnId, toIndex);
const to = locate(next, cardId)!;
if (to.columnId === from.columnId && to.index === from.index) return null;
setBoardState(next);
pushHistory({ type: "move", cardId, title: card.title, from, to });
if (!quiet) {
const msg =
to.columnId === from.columnId
? `Moved “${card.title}” to position ${to.index + 1} in ${columnTitle(to.columnId)}.`
: `Moved “${card.title}” to ${columnTitle(to.columnId)}.`;
showToast(msg, "info", true);
}
return null;
}
const { drag, onPointerDown, shouldSuppressClick } = usePointerDrag({
boardRef,
isBlocked: (columnId, cardId) => {
const from = locate(board, cardId);
return !!from && from.columnId !== columnId && wouldExceedLimit(board, columnId, cardId);
},
onDrop: (cardId, columnId, beforeId) => {
commitMove(cardId, columnId, indexBefore(board, columnId, cardId, beforeId));
},
onBlockedDrop: (_cardId, columnId) => {
const msg = limitMessage(columnId);
showToast(msg, "warn");
announce(msg);
},
onStart: () => {
setMoveMenu(null);
if (kbd) setKbd(null);
},
});
// ----- Keyboard moves ---------------------------------------------------
useLayoutEffect(() => {
if (!kbd) return;
const el = document.querySelector<HTMLElement>(`[data-card-id="${kbd.cardId}"]`);
if (el && document.activeElement !== el) el.focus({ preventScroll: true });
el?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [kbd, board]);
function pickUp(cardId: string) {
const origin = locate(board, cardId);
if (!origin) return;
setMoveMenu(null);
setKbd({ cardId, origin });
announce(
`Picked up “${board.cards[cardId].title}”. ${positionText(board, cardId)}. Use the arrow keys to move, Space to drop, Escape to cancel.`,
);
}
function kbdStep(direction: "up" | "down" | "left" | "right") {
if (!kbd) return;
const id = kbd.cardId;
const loc = locate(board, id);
if (!loc) return;
const visible = visibleByColumn[loc.columnId];
const vi = visible.indexOf(id);
let target: { columnId: string; beforeId: string | null } | null = null;
if (direction === "up") {
if (vi <= 0) return announce(`Already at the top of ${columnTitle(loc.columnId)}.`);
target = { columnId: loc.columnId, beforeId: visible[vi - 1] };
} else if (direction === "down") {
if (vi === -1 || vi >= visible.length - 1) return announce(`Already at the bottom of ${columnTitle(loc.columnId)}.`);
target = { columnId: loc.columnId, beforeId: visible[vi + 2] ?? null };
} else {
const ci = board.columns.findIndex((c) => c.id === loc.columnId);
const next = board.columns[ci + (direction === "left" ? -1 : 1)];
if (!next) return announce(`No column to the ${direction}. “${board.cards[id].title}” stays in ${columnTitle(loc.columnId)}.`);
if (wouldExceedLimit(board, next.id, id)) {
const msg = limitMessage(next.id);
showToast(msg, "warn");
return announce(msg);
}
const targetVisible = visibleByColumn[next.id].filter((x) => x !== id);
target = { columnId: next.id, beforeId: targetVisible[Math.max(0, vi)] ?? null };
}
const nextBoard = moveCard(board, id, target.columnId, indexBefore(board, target.columnId, id, target.beforeId));
setBoardState(nextBoard);
announce(`Moved to ${positionText(nextBoard, id)}.`);
}
function kbdDrop() {
if (!kbd) return;
const id = kbd.cardId;
const to = locate(board, id);
const card = board.cards[id];
setKbd(null);
if (!to || !card) return;
if (to.columnId !== kbd.origin.columnId || to.index !== kbd.origin.index) {
pushHistory({ type: "move", cardId: id, title: card.title, from: kbd.origin, to });
announce(`Dropped “${card.title}” in ${positionText(board, id)}.`);
} else {
announce(`Dropped “${card.title}”. It stays in ${positionText(board, id)}.`);
}
}
function kbdCancel() {
if (!kbd) return;
const { cardId, origin } = kbd;
const restored = moveCard(board, cardId, origin.columnId, origin.index);
setBoardState(restored);
setKbd(null);
announce(`Move cancelled. “${board.cards[cardId].title}” is back in ${positionText(restored, cardId)}.`);
}
function focusNeighbour(cardId: string, key: string) {
const loc = locate(board, cardId);
if (!loc) return;
const visible = visibleByColumn[loc.columnId];
const vi = visible.indexOf(cardId);
if (key === "ArrowUp" && vi > 0) focusCard(visible[vi - 1]);
if (key === "ArrowDown" && vi < visible.length - 1) focusCard(visible[vi + 1]);
if (key === "ArrowLeft" || key === "ArrowRight") {
const ci = board.columns.findIndex((c) => c.id === loc.columnId);
const step = key === "ArrowLeft" ? -1 : 1;
for (let i = ci + step; i >= 0 && i < board.columns.length; i += step) {
const ids = visibleByColumn[board.columns[i].id];
if (ids.length) {
focusCard(ids[Math.min(vi, ids.length - 1)]);
break;
}
}
}
}
function onCardKeyDown(e: React.KeyboardEvent<HTMLElement>, cardId: string) {
if (e.target !== e.currentTarget || e.altKey || e.ctrlKey || e.metaKey) return;
if (kbd?.cardId === cardId) {
const dirs: Record<string, "up" | "down" | "left" | "right"> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
if (dirs[e.key]) {
e.preventDefault();
kbdStep(dirs[e.key]);
} else if (e.key === " " || e.key === "Enter") {
e.preventDefault();
kbdDrop();
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
kbdCancel();
}
return;
}
if (e.key === "Enter") {
e.preventDefault();
openCard(cardId);
} else if (e.key === " ") {
e.preventDefault();
pickUp(cardId);
} else if (e.key.startsWith("Arrow")) {
e.preventDefault();
focusNeighbour(cardId, e.key);
}
}
function onCardBlur(e: React.FocusEvent<HTMLElement>, cardId: string) {
// Leaving a picked-up card for another control drops it where it is.
if (kbd?.cardId === cardId && e.relatedTarget && !e.currentTarget.contains(e.relatedTarget as Node)) {
kbdDrop();
}
}
// ----- Card actions -----------------------------------------------------
function openCard(cardId: string) {
if (shouldSuppressClick() || kbd) return;
setMoveMenu(null);
setOpenCardId(cardId);
}
function closeCard() {
const id = openCardId;
setOpenCardId(null);
if (id) focusCard(id, locate(board, id)?.columnId);
}
function createCard(columnId: string, title: string): string | null {
if (wouldExceedLimit(board, columnId, null)) {
const msg = limitMessage(columnId, true);
announce(msg);
return msg;
}
const card: Card = {
id: makeId("c"),
title,
description: "",
assignee: null,
labels: [],
priority: "medium",
due: null,
checklist: [],
};
const column = getColumn(board, columnId)!;
setBoardState(insertCard(board, card, columnId, column.card_ids.length));
pushHistory({ type: "create", cardId: card.id, title, columnId });
const hidden = filtered && !matchesFilters(card, filters);
showToast(
`Added “${title}” to ${column.title}.${hidden ? " It’s hidden by the current filters." : ""}`,
"info",
true,
);
return null;
}
function deleteCard(cardId: string) {
const from = locate(board, cardId);
const card = board.cards[cardId];
if (!from || !card) return;
setBoardState(removeCard(board, cardId));
pushHistory({ type: "delete", card, from });
setOpenCardId(null);
showToast(`Deleted “${card.title}”.`, "info", true);
const visible = visibleByColumn[from.columnId].filter((id) => id !== cardId);
const vi = Math.min(visibleByColumn[from.columnId].indexOf(cardId), visible.length - 1);
if (vi >= 0) focusCard(visible[vi]);
else
requestAnimationFrame(() =>
document.querySelector<HTMLElement>(`[data-column-id="${from.columnId}"] .add-card-btn`)?.focus(),
);
}
// ----- Undo -------------------------------------------------------------
function undo() {
const entry = history[history.length - 1];
if (!entry || kbd) return;
const dropEntry = () => setHistory((h) => h.slice(0, -1));
if (entry.type === "move") {
const loc = locate(board, entry.cardId);
if (!loc) {
dropEntry();
showToast(`Nothing to undo: “${entry.title}” is no longer on the board.`, "warn");
return;
}
if (loc.columnId !== entry.from.columnId && wouldExceedLimit(board, entry.from.columnId, entry.cardId)) {
const msg = `Can’t undo yet. ${limitMessage(entry.from.columnId)}`;
showToast(msg, "warn");
announce(msg);
return;
}
setBoardState(moveCard(board, entry.cardId, entry.from.columnId, entry.from.index));
dropEntry();
const msg = `Undone. “${entry.title}” is back in ${columnTitle(entry.from.columnId)}.`;
showToast(msg);
announce(msg);
} else if (entry.type === "create") {
if (board.cards[entry.cardId]) setBoardState(removeCard(board, entry.cardId));
if (openCardId === entry.cardId) setOpenCardId(null);
dropEntry();
const msg = `Undone. “${entry.title}” was removed from ${columnTitle(entry.columnId)}.`;
showToast(msg);
announce(msg);
} else {
if (wouldExceedLimit(board, entry.from.columnId, null)) {
const msg = `Can’t restore yet. ${limitMessage(entry.from.columnId)}`;
showToast(msg, "warn");
announce(msg);
return;
}
setBoardState(insertCard(board, entry.card, entry.from.columnId, entry.from.index));
dropEntry();
const msg = `Restored “${entry.card.title}” to ${columnTitle(entry.from.columnId)}.`;
showToast(msg);
announce(msg);
}
}
const undoRef = useRef(undo);
undoRef.current = undo;
const modalOpen = openCardId !== null || confirmReset;
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key.toLowerCase() !== "z" || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
if (isTextField(e.target) || modalOpen) return;
e.preventDefault();
undoRef.current();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [modalOpen]);
// ----- Reset ------------------------------------------------------------
function resetDemo() {
clearSaved();
setBoardState(cloneBoard(initial));
setHistory([]);
setKbd(null);
setMoveMenu(null);
setOpenCardId(null);
setFilters(EMPTY_FILTERS);
setConfirmReset(false);
showToast("The board is back to the original demo data.");
requestAnimationFrame(() => resetBtnRef.current?.focus());
}
// ----- Render -----------------------------------------------------------
const lastEntry = history[history.length - 1];
const undoDescription = lastEntry
? lastEntry.type === "move"
? `Undo moving “${lastEntry.title}”`
: lastEntry.type === "create"
? `Undo adding “${lastEntry.title}”`
: `Undo deleting “${lastEntry.card.title}”`
: "Nothing to undo";
const openCard_ = openCardId ? board.cards[openCardId] : null;
const openLoc = openCardId ? locate(board, openCardId) : null;
const menuCard = moveMenu ? board.cards[moveMenu.cardId] : null;
const menuLoc = moveMenu ? locate(board, moveMenu.cardId) : null;
const kbdCard = kbd ? board.cards[kbd.cardId] : null;
const closeMoveMenu = useCallback((restoreFocus: boolean) => {
setMoveMenu((m) => {
if (m && restoreFocus) requestAnimationFrame(() => m.anchor.focus());
return null;
});
}, []);
return (
<>
<div className="app" inert={modalOpen}>
<header className="topbar">
<div className="topbar-brand">
<Wordmark />
<span className="topbar-divider" aria-hidden="true" />
<span className="workspace">
<span className="workspace-tile" aria-hidden="true">
HS
</span>
<span className="workspace-name">{meta.workspace}</span>
</span>
</div>
<div className="topbar-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={undo}
disabled={!lastEntry}
title={lastEntry ? `${undoDescription} (Ctrl+Z / ⌘Z)` : "Nothing to undo"}
aria-describedby="undo-desc"
>
<IconUndo size={14} />
<span className="btn-label">Undo</span>
</button>
<span id="undo-desc" hidden>
{undoDescription}
</span>
<button
ref={resetBtnRef}
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setConfirmReset(true)}
>
<IconReset size={14} />
<span className="btn-label">Reset demo</span>
</button>
</div>
</header>
<div className="board-head">
<h1 className="board-title">{meta.board}</h1>
<ul className="board-stats" aria-label="Board summary">
<li>
<strong>{totalCards}</strong> cards
</li>
<li data-tone={overdueCount ? "overdue" : undefined}>
<strong>{overdueCount}</strong> overdue
</li>
<li>
Today <strong>{mediumDate(meta.today)}</strong>
</li>
</ul>
<div className="team" role="group" aria-label="Show cards by teammate">
{meta.members.map((m) => {
const active = filters.assignee === m.id;
return (
<button
key={m.id}
type="button"
className="team-btn"
aria-pressed={active}
title={`${m.name} · ${m.role}`}
onClick={() => setFilters((f) => ({ ...f, assignee: active ? "all" : m.id }))}
>
<Avatar member={m} size="md" />
<span className="sr-only">Show only cards assigned to {m.name}</span>
</button>
);
})}
</div>
</div>
<FilterBar
meta={meta}
filters={filters}
setFilters={setFilters}
visible={visibleCards}
total={totalCards}
/>
<nav className="column-jump" aria-label="Jump to column">
{board.columns.map((c) => (
<button
key={c.id}
type="button"
className="column-jump-btn"
onClick={() => {
const el = document.querySelector<HTMLElement>(`[data-column-id="${c.id}"]`);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
el?.scrollIntoView({ behavior: reduce ? "auto" : "smooth", inline: "start", block: "nearest" });
}}
>
{c.title}
<span className="column-jump-count">{visibleByColumn[c.id].length}</span>
</button>
))}
</nav>
{filtered && visibleCards === 0 && (
<div className="empty-results" role="status">
<IconSearch size={20} />
<div>
<p className="empty-title">No cards match these filters</p>
<p className="empty-body">
{filters.query.trim()
? `Nothing in titles or descriptions mentions “${filters.query.trim()}” with the current person and label.`
: "No card has that combination of person and label."}{" "}
Try a broader search or clear the filters.
</p>
</div>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setFilters(EMPTY_FILTERS)}>
<IconFilterOff size={14} /> Clear all filters
</button>
</div>
)}
<main className="board" ref={boardRef} aria-label={`${meta.board} board`} data-dragging={drag ? true : undefined}>
{board.columns.map((column) => (
<ColumnView
key={column.id}
column={column}
visibleIds={visibleByColumn[column.id]}
cards={board.cards}
meta={meta}
filtered={filtered}
drag={drag}
pickedId={kbd?.cardId ?? null}
menuCardId={moveMenu?.cardId ?? null}
onOpen={openCard}
onCardPointerDown={onPointerDown}
onCardKeyDown={onCardKeyDown}
onCardBlur={onCardBlur}
onMoveClick={(anchor, cardId) =>
setMoveMenu((m) => (m?.cardId === cardId ? null : { anchor, cardId }))
}
onCreate={createCard}
/>
))}
</main>
<footer className="app-footer">
<p className="notice-text">{FICTIONAL_NOTICE}</p>
<p className="footer-meta">Changes are saved in this browser only.</p>
</footer>
</div>
<p id="card-keyboard-help" hidden>
Press Enter to open details. Press Space to pick up, then use the arrow keys to move, Space to drop, or Escape
to cancel.
</p>
<div className="sr-only" aria-live="assertive" aria-atomic="true">
{live}
</div>
{kbdCard && (
<div className="kbd-hint" aria-hidden="true">
<span className="kbd-hint-title">Moving “{kbdCard.title}”</span>
<span>
<kbd>↑</kbd>
<kbd>↓</kbd> reorder
</span>
<span>
<kbd>←</kbd>
<kbd>→</kbd> column
</span>
<span>
<kbd>Space</kbd> drop
</span>
<span>
<kbd>Esc</kbd> cancel
</span>
</div>
)}
<div className="toast-region" aria-live="polite" aria-atomic="true">
{toast && (
<div className="toast" data-tone={toast.tone} key={toast.id}>
{toast.tone === "warn" && <IconAlert size={16} />}
<p>{toast.message}</p>
{toast.undo && lastEntry && (
<button
type="button"
className="toast-action"
onClick={() => {
undo();
}}
>
Undo
</button>
)}
<button type="button" className="toast-close" aria-label="Dismiss message" onClick={() => setToast(null)}>
<IconClose size={14} />
</button>
</div>
)}
</div>
{moveMenu && menuCard && menuLoc && (
<MoveMenu
anchor={moveMenu.anchor}
cardTitle={menuCard.title}
columns={board.columns}
currentColumnId={menuLoc.columnId}
isFull={(columnId) => wouldExceedLimit(board, columnId, moveMenu.cardId)}
onClose={closeMoveMenu}
onSelect={(columnId) => {
const cardId = moveMenu.cardId;
const target = getColumn(board, columnId)!;
const blocked = commitMove(cardId, columnId, target.card_ids.length);
setMoveMenu(null);
if (blocked) requestAnimationFrame(() => moveMenu.anchor.focus());
else focusCard(cardId, columnId);
}}
/>
)}
{openCard_ && openLoc && (
<CardDialog
key={openCard_.id}
card={openCard_}
columnId={openLoc.columnId}
columns={board.columns}
meta={meta}
onChange={(card) => setBoardState((b) => updateCard(b, card))}
onMoveColumn={(columnId) => {
const target = getColumn(board, columnId)!;
return commitMove(openCard_.id, columnId, target.card_ids.length);
}}
onDelete={() => deleteCard(openCard_.id)}
onClose={closeCard}
/>
)}
{confirmReset && (
<Modal
labelledBy="reset-title"
describedBy="reset-desc"
role="alertdialog"
className="confirm-dialog"
onClose={() => {
setConfirmReset(false);
requestAnimationFrame(() => resetBtnRef.current?.focus());
}}
>
<h2 id="reset-title" className="confirm-title">
Reset the demo board?
</h2>
<p id="reset-desc" className="confirm-body">
This brings back the original {meta.board} cards and discards your changes in this browser, including the
undo history.
</p>
<div className="confirm-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setConfirmReset(false);
requestAnimationFrame(() => resetBtnRef.current?.focus());
}}
>
Keep my board
</button>
<button type="button" className="btn btn-danger" onClick={resetDemo}>
Reset board
</button>
</div>
</Modal>
)}
</>
);
}
function FilterBar({
meta,
filters,
setFilters,
visible,
total,
}: {
meta: BoardMeta;
filters: Filters;
setFilters: React.Dispatch<React.SetStateAction<Filters>>;
visible: number;
total: number;
}) {
const active = hasActiveFilters(filters);
return (
<div className="filterbar" role="search" aria-label="Filter cards">
<div className="search-field">
<IconSearch size={15} />
<input
type="search"
className="search-input"
placeholder="Search titles and descriptions"
aria-label="Search cards by title or description"
value={filters.query}
onChange={(e) => setFilters((f) => ({ ...f, query: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Escape" && filters.query) {
e.preventDefault();
setFilters((f) => ({ ...f, query: "" }));
}
}}
/>
</div>
<label className="filter-select" data-active={filters.assignee !== "all" || undefined}>
<span className="filter-select-label">Assignee</span>
<select
value={filters.assignee}
onChange={(e) => setFilters((f) => ({ ...f, assignee: e.target.value }))}
>
<option value="all">Everyone</option>
<option value="unassigned">Unassigned</option>
{meta.members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</label>
<label className="filter-select" data-active={filters.label !== "all" || undefined}>
<span className="filter-select-label">Label</span>
<select value={filters.label} onChange={(e) => setFilters((f) => ({ ...f, label: e.target.value }))}>
<option value="all">All labels</option>
{meta.labels.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</label>
<p className="filter-summary" aria-live="polite">
{active ? (
<>
Showing <strong>{visible}</strong> of {total} cards
</>
) : (
<>All {total} cards</>
)}
</p>
{active && (
<button type="button" className="btn btn-ghost btn-sm clear-filters" onClick={() => setFilters(EMPTY_FILTERS)}>
<IconClose size={14} /> Clear filters
</button>
)}
</div>
);
}