diff --git a/src/components.tsx b/src/components.tsx index 48df040..0387c2b 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -113,6 +113,12 @@ const ICON_ARROW_LEFT = icon( , ); +const ICON_ARROW_UP = icon( + <> + + + , +); const ICON_PLUS = icon( <> @@ -194,6 +200,7 @@ export const Icons = { share: ICON_SHARE, check: ICON_CHECK, arrowLeft: ICON_ARROW_LEFT, + arrowUp: ICON_ARROW_UP, plus: ICON_PLUS, more: ICON_MORE, image: ICON_IMAGE, diff --git a/src/styles.css b/src/styles.css index 38a21ee..a906920 100644 --- a/src/styles.css +++ b/src/styles.css @@ -779,6 +779,7 @@ input[type="checkbox"] { scrolled-past rows still show. */ --content-pad-top: var(--space-5); --group-header-h: 40px; + --section-nav-h: 48px; flex: 1; overflow: auto; padding: var(--content-pad-top) var(--space-6); @@ -965,7 +966,7 @@ h4 { * tag) since h3 is also used for .dialog-title, which must not be sticky. */ .group-header { position: sticky; - top: calc(-1 * var(--content-pad-top, 0px)); + top: calc(var(--sticky-offset, 0px) - var(--content-pad-top, 0px)); z-index: 5; margin: var(--space-4) 0 var(--space-2); /* A fixed height, so a grouped table's column headers can pin just below @@ -1582,7 +1583,7 @@ th { letter-spacing: var(--tracking-label); color: var(--muted); position: sticky; - top: calc(-1 * var(--content-pad-top, 0px)); + top: calc(var(--sticky-offset, 0px) - var(--content-pad-top, 0px)); background: var(--bg-content); border-bottom: 1px solid var(--border); z-index: 1; @@ -1594,7 +1595,7 @@ td { } /* Under a sticky group header, pin below it instead of behind it. */ .group-header + table th { - top: calc(var(--group-header-h) - var(--content-pad-top, 0px)); + top: calc(var(--sticky-offset, 0px) + var(--group-header-h) - var(--content-pad-top, 0px)); } tbody tr { transition: background 0.08s; @@ -3139,6 +3140,115 @@ button.pull-action { margin: 4px 0 18px; } +/* Section bar (see `SectionNav`): pins under the top bar, so group headers + and column headers below it pin under it in turn via --sticky-offset. */ +.deck-builder { + --sticky-offset: var(--section-nav-h); +} +.deck-section { + scroll-margin-top: calc(var(--section-nav-h) + var(--space-2)); +} +.section-nav { + position: sticky; + top: calc(-1 * var(--content-pad-top, 0px)); + z-index: 6; + height: var(--section-nav-h); + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-3); + background: var(--bg-content); + border-bottom: 1px solid var(--border-soft); + /* Tabs or jump menu is decided by the bar's own width (the nav rail eats + into the window). Not on .deck-builder: containment there would trap + its inline position: fixed dialogs. */ + container-type: inline-size; +} +.section-nav button { + font-size: 13px; + white-space: nowrap; +} +/* The bar's own buttons (not the jump menu's items) share one height. */ +.section-nav > button, +.section-nav-tabs button, +.section-nav-jump > button { + height: 32px; + padding: 0 10px; +} +.section-nav .section-nav-top { + flex-shrink: 0; + justify-content: center; + width: 32px; + padding: 0; +} +.section-nav-top svg { + width: 16px; + height: 16px; +} +.section-nav-tabs { + display: flex; + gap: var(--space-1); + min-width: 0; +} +.section-nav-tabs button { + background: transparent; + border-color: transparent; + color: var(--muted); +} +.section-nav-tabs button:hover { + background: var(--bg-hover); + color: var(--text); +} +.section-nav-tabs button.active { + background: var(--accent-subtle); + border-color: var(--accent-glow); + color: var(--text-strong); +} +.section-nav-tabs button.empty:not(.active) { + color: var(--faint); +} +.section-nav-jump { + position: relative; + display: none; + min-width: 0; + max-width: 50%; +} +.section-nav-jump > button { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; +} +.section-nav-jump-label { + overflow: hidden; + text-overflow: ellipsis; +} +.section-nav-menu { + min-width: 220px; +} +.section-nav-filter { + height: 32px; + box-sizing: border-box; + flex: 1; + margin-left: auto; + min-width: 140px; + max-width: 240px; +} +/* Below this the longest tab set (Available / In other decks / Not owned / + Maybeboard, with counts) plus a minimum-width filter no longer fits. */ +@container (max-width: 740px) { + .section-nav-tabs { + display: none; + } + .section-nav-jump { + display: block; + } + .section-nav-filter { + max-width: none; + } +} + /* The four top-of-page info panels (commander, meta, analysis, legality). Single column matches the existing top-to-bottom reading order on narrow/portrait viewports; on wide viewports the compact panels move diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index c029ecc..d63b1cf 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -206,6 +206,103 @@ const STATUS_HINTS: Record = { 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(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 ( +
+ + +
+ + {open && ( +
+ {sections.map((s) => ( + + ))} +
+ )} +
+ onFilterChange(e.target.value)} + placeholder="Filter cards in deck…" + /> +
+ ); +} + /** * 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(null); + const sectionRefs = useRef>({}); + 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 ( -
+
+ + {selectedCards.size > 0 && (
@@ -1208,7 +1371,7 @@ function DeckBuilder({ )} {combineSections ? ( -
+

All cards {mainboard.length}

@@ -1223,7 +1386,7 @@ function DeckBuilder({
) : physical ? ( <> -
+

To pull {toPull.length}

@@ -1249,7 +1412,7 @@ function DeckBuilder({ )}
-
+

In deck {inDeck.length}

@@ -1258,7 +1421,7 @@ function DeckBuilder({ ) : ( <> -
+

Available {available.length}

@@ -1276,7 +1439,7 @@ function DeckBuilder({
{elsewhere.length > 0 && ( -
+

In other decks {elsewhere.length}

@@ -1287,7 +1450,7 @@ function DeckBuilder({
)} -
+

Not owned {unowned.length}

@@ -1303,7 +1466,7 @@ function DeckBuilder({ )} -
+

Maybeboard {maybeboard.length}