Deck builder: sticky section bar for jumping between sections

A bar below the toolbar pins under the top bar and jumps between the card
sections (Available / In other decks / Not owned, To pull / In deck, or
All cards, plus Maybeboard), marking the one in view. Where it fits it
shows inline tabs; on a narrow bar, one button names the current section
and opens a menu of them all, so nothing scrolls sideways. The switch
follows the bar's own width via a container query. It also holds the
deck filter and an up button back to the overview. Group headers and
column headers pin below the bar.

Closes #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 21:13:29 -05:00
parent 1a23bd83a4
commit 70b4d5427b
3 changed files with 297 additions and 17 deletions

View File

@@ -206,6 +206,103 @@ const STATUS_HINTS: Record<DeckStatus, string> = {
const STARTING_HAND_SIZE = 7;
interface NavSection {
id: string;
label: string;
count: number;
}
/**
* The deck builder's sticky section bar: jumps between its card sections,
* marks the one in view, and holds the filter. Inline tabs where they fit;
* on a narrow bar, one button naming the current section opens a menu of
* them all instead (the CSS picks by the bar's own width).
*/
function SectionNav({
sections,
active,
onJump,
onTop,
filter,
onFilterChange,
}: {
sections: NavSection[];
active: string;
onJump: (id: string) => void;
onTop: () => void;
filter: string;
onFilterChange: (q: string) => void;
}) {
const [open, setOpen] = useState(false);
const jumpRef = useRef<HTMLDivElement>(null);
const current = sections.find((s) => s.id === active) ?? sections[0];
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (jumpRef.current && !jumpRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
window.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
window.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div className="section-nav">
<button className="section-nav-top" onClick={onTop} title="Back to the deck overview" aria-label="Back to the deck overview">
{Icons.arrowUp}
</button>
<nav className="section-nav-tabs" aria-label="Deck sections">
{sections.map((s) => (
<button
key={s.id}
className={[s.id === active ? "active" : "", s.count === 0 ? "empty" : ""].filter(Boolean).join(" ")}
aria-current={s.id === active ? "location" : undefined}
onClick={() => onJump(s.id)}
>
{s.label} <span className="badge">{s.count}</span>
</button>
))}
</nav>
<div className="section-nav-jump" ref={jumpRef}>
<button onClick={() => setOpen(!open)} aria-haspopup="menu" aria-expanded={open} title="Jump to a section">
<span className="section-nav-jump-label">{current.label}</span>
<span className="badge">{current.count}</span> ▾
</button>
{open && (
<div className="library-menu section-nav-menu" role="menu">
{sections.map((s) => (
<button
key={s.id}
role="menuitemradio"
aria-checked={s.id === active}
onClick={() => {
setOpen(false);
onJump(s.id);
}}
>
<span className="library-menu-check">{s.id === active ? "✓" : ""}</span>
{s.label}
<span className="hint">{s.count}</span>
</button>
))}
</div>
)}
</div>
<input
className="section-nav-filter"
value={filter}
onChange={(e) => onFilterChange(e.target.value)}
placeholder="Filter cards in deck…"
/>
</div>
);
}
/**
* Deck editor. A deck's mainboard cards are split into two groups — "In your
* library" (owned) and "Not owned" — each with its own search box and each
@@ -692,6 +789,69 @@ function DeckBuilder({
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
// The card sections below, in order, for the section bar to jump between.
// Mirrors the branches that render them.
const sections: NavSection[] = [
...(combineSections
? [{ id: "all", label: "All cards", count: mainboard.length }]
: physical
? [
{ id: "pull", label: "To pull", count: toPull.length },
{ id: "in-deck", label: "In deck", count: inDeck.length },
]
: [
{ id: "available", label: "Available", count: available.length },
...(elsewhere.length > 0 ? [{ id: "elsewhere", label: "In other decks", count: elsewhere.length }] : []),
{ id: "unowned", label: "Not owned", count: unowned.length },
]),
{ id: "maybe", label: "Maybeboard", count: maybeboard.length },
];
const rootRef = useRef<HTMLDivElement>(null);
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
const sectionRef = (id: string) => (el: HTMLElement | null) => {
sectionRefs.current[id] = el;
};
const [activeSection, setActiveSection] = useState(sections[0].id);
const sectionIds = sections.map((s) => s.id).join(",");
// Scroll-spy: the current section is the last one whose top has reached
// where a jump lands it (just under the bar, per its scroll-margin) — or
// the last one outright once scrolled to the bottom, so a short
// Maybeboard can still become current.
useEffect(() => {
const scroller = rootRef.current?.closest(".content");
if (!scroller) return;
let frame = 0;
const update = () => {
frame = 0;
const ids = sectionIds.split(",");
const top = scroller.getBoundingClientRect().top;
let current = ids[0];
for (const id of ids) {
const el = sectionRefs.current[id];
if (el && el.getBoundingClientRect().top <= top + parseFloat(getComputedStyle(el).scrollMarginTop) + 1) {
current = id;
}
}
if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2) current = ids[ids.length - 1];
setActiveSection(current);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
scroller.addEventListener("scroll", onScroll, { passive: true });
return () => {
scroller.removeEventListener("scroll", onScroll);
cancelAnimationFrame(frame);
};
}, [sectionIds]);
const scrollBehavior = (): ScrollBehavior =>
window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
const jumpTo = (id: string) => sectionRefs.current[id]?.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
const jumpToTop = () => rootRef.current?.closest(".content")?.scrollTo({ top: 0, behavior: scrollBehavior() });
// Lets the click preview track copies and choose each slot's printing.
const preview: PreviewContext = {
deckId,
@@ -913,7 +1073,7 @@ function DeckBuilder({
}
return (
<div className="deck-builder">
<div className="deck-builder" ref={rootRef}>
<button className="deck-builder-back" onClick={onBack}>
{Icons.arrowLeft}
All decks
@@ -1140,12 +1300,6 @@ function DeckBuilder({
Combine sections
</span>
</label>
<input
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
placeholder="Filter cards in deck…"
style={{ width: 200 }}
/>
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
@@ -1153,6 +1307,15 @@ function DeckBuilder({
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
</div>
<SectionNav
sections={sections}
active={activeSection}
onJump={jumpTo}
onTop={jumpToTop}
filter={filterQuery}
onFilterChange={setFilterQuery}
/>
{selectedCards.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">
@@ -1208,7 +1371,7 @@ function DeckBuilder({
)}
{combineSections ? (
<section style={{ marginTop: 10 }}>
<section className="deck-section" ref={sectionRef("all")} style={{ marginTop: 10 }}>
<h3>
All cards <span className="badge">{mainboard.length}</span>
</h3>
@@ -1223,7 +1386,7 @@ function DeckBuilder({
</section>
) : physical ? (
<>
<section style={{ marginTop: 10 }}>
<section className="deck-section" ref={sectionRef("pull")} style={{ marginTop: 10 }}>
<h3>
To pull <span className="badge">{toPull.length}</span>
</h3>
@@ -1249,7 +1412,7 @@ function DeckBuilder({
)}
</section>
<section style={{ marginTop: 26 }}>
<section className="deck-section" ref={sectionRef("in-deck")} style={{ marginTop: 26 }}>
<h3>
In deck <span className="badge">{inDeck.length}</span>
</h3>
@@ -1258,7 +1421,7 @@ function DeckBuilder({
</>
) : (
<>
<section style={{ marginTop: 10 }}>
<section className="deck-section" ref={sectionRef("available")} style={{ marginTop: 10 }}>
<h3>
Available <span className="badge">{available.length}</span>
</h3>
@@ -1276,7 +1439,7 @@ function DeckBuilder({
</section>
{elsewhere.length > 0 && (
<section style={{ marginTop: 26 }}>
<section className="deck-section" ref={sectionRef("elsewhere")} style={{ marginTop: 26 }}>
<h3>
In other decks <span className="badge">{elsewhere.length}</span>
</h3>
@@ -1287,7 +1450,7 @@ function DeckBuilder({
</section>
)}
<section style={{ marginTop: 26 }}>
<section className="deck-section" ref={sectionRef("unowned")} style={{ marginTop: 26 }}>
<h3>
Not owned <span className="badge">{unowned.length}</span>
</h3>
@@ -1303,7 +1466,7 @@ function DeckBuilder({
</>
)}
<section style={{ marginTop: 26 }}>
<section className="deck-section" ref={sectionRef("maybe")} style={{ marginTop: 26 }}>
<h3>
Maybeboard <span className="badge">{maybeboard.length}</span>
</h3>