From d6e526f013328fd9bcf72ec93fa10133868927e5 Mon Sep 17 00:00:00 2001 From: jescinta Date: Mon, 19 Oct 2026 23:12:30 +0100 Subject: [PATCH] feat: category filters, back-to-top, module detail breadcrumbs, search shortcut Closes #124 Closes #125 Closes #126 Closes #127 - Add category filter chips (Environment, Economics, Assertions, Security, Storage) that combine with the text search, keyboard-accessible with aria-pressed state - Add a back-to-top button that appears after scrolling past 480px and smoothly returns to the header - Add module detail pages with breadcrumb navigation (Home / Modules / module); module cards are keyboard-activatable buttons - Add a '/' keyboard shortcut to jump to the module search field; search covers names and descriptions with real-time filtering --- README.md | 5 +- src/App.test.tsx | 164 ++++++++++++++++++++++++++++-- src/App.tsx | 260 +++++++++++++++++++++++++++++++++++++---------- src/styles.css | 122 ++++++++++++++++++++++ src/testkit.ts | 30 ++++-- 5 files changed, 510 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index ccbaaa0..fc88034 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,10 @@ This workflow is useful for exploring the exact resource ceiling of auth-heavy o ## Features - **Design System & Theme Tokens**: Comprehensive CSS custom property scale (`:root`) standardizing color schemes (canvas, surfaces, accent, text hierarchy, borders) and spacing increments. -- **Module Filter & Empty State**: Search and filter testing modules by name or description with real-time feedback and an accessible empty-state layout with quick filter reset. +- **Module Search**: Search testing modules by name or description with real-time feedback and an accessible empty-state layout with quick filter reset. Press `/` anywhere to jump to the search field. +- **Category Filters**: Filter the module directory by category (Environment, Economics, Assertions, Security, Storage) using keyboard-accessible chips that combine with the text search. +- **Module Detail Pages**: Activate a module card (click or keyboard) to open a dedicated detail view with breadcrumb navigation (Home / Modules / module) and a clear path back to the directory. +- **Back-to-Top Control**: A keyboard-accessible back-to-top button appears after scrolling and smoothly returns to the page header. - **Responsive & Reduced-Width Navigation**: Accessible navigation layout supporting truncated and responsive labels without overflowing viewport on mobile or narrow screens. - **Required vs. Optional Setting Distinction**: Visual badges (`Required` / `Optional`) and `aria-required` attributes in the CLI workbench distinguishing mandatory parameters from optional configuration flags. - **Optional Argument Reordering**: Coverage and audit options can be reordered with keyboard-accessible up/down controls so advanced users can put the most important flags first. diff --git a/src/App.test.tsx b/src/App.test.tsx index 816941b..0ea8fb9 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import App from "./App"; @@ -78,7 +78,7 @@ describe("Module filter and empty-state layout (#188)", () => { it("filters modules matching search query by name or description", () => { render(); - const filterInput = screen.getByLabelText("Filter modules"); + const filterInput = screen.getByLabelText("Search modules by name or description"); fireEvent.change(filterInput, { target: { value: "ledger" } }); @@ -89,7 +89,7 @@ describe("Module filter and empty-state layout (#188)", () => { it("renders an accessible empty-state layout when no modules match the filter", () => { render(); - const filterInput = screen.getByLabelText("Filter modules"); + const filterInput = screen.getByLabelText("Search modules by name or description"); fireEvent.change(filterInput, { target: { value: "nonexistent-module" } }); @@ -100,7 +100,7 @@ describe("Module filter and empty-state layout (#188)", () => { screen.getByText(/No modules match/i), ).toBeInTheDocument(); - const resetButton = screen.getByRole("button", { name: "Reset filter" }); + const resetButton = screen.getByRole("button", { name: "Reset filters" }); expect(resetButton).toBeInTheDocument(); fireEvent.click(resetButton); @@ -111,19 +111,171 @@ describe("Module filter and empty-state layout (#188)", () => { it("clears the filter using the Clear button", () => { render(); - const filterInput = screen.getByLabelText("Filter modules"); + const filterInput = screen.getByLabelText("Search modules by name or description"); fireEvent.change(filterInput, { target: { value: "events" } }); expect(screen.getByText("events")).toBeInTheDocument(); expect(screen.queryByText("ttl")).not.toBeInTheDocument(); - const clearButton = screen.getByRole("button", { name: "Clear module filter" }); + const clearButton = screen.getByRole("button", { name: "Clear module search" }); fireEvent.click(clearButton); expect(screen.getByText("ttl")).toBeInTheDocument(); }); }); +describe("Category filters (#124)", () => { + it("renders category filter chips including an All option", () => { + render(); + const group = screen.getByRole("group", { name: /filter modules by category/i }); + expect(group).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Environment" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Economics" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Security" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Storage" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Assertions" })).toBeInTheDocument(); + }); + + it("filters modules when a category chip is selected", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Environment" })); + + expect(screen.getByText("core")).toBeInTheDocument(); + expect(screen.getByText("ledger")).toBeInTheDocument(); + expect(screen.queryByText("money")).not.toBeInTheDocument(); + expect(screen.queryByText("auth")).not.toBeInTheDocument(); + }); + + it("toggles category off when the active chip is clicked again", () => { + render(); + const envChip = screen.getByRole("button", { name: "Environment" }); + fireEvent.click(envChip); + expect(screen.queryByText("money")).not.toBeInTheDocument(); + + fireEvent.click(envChip); + expect(screen.getByText("money")).toBeInTheDocument(); + expect(screen.getByText("core")).toBeInTheDocument(); + }); + + it("combines category filter with the text search", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Economics" })); + const filterInput = screen.getByLabelText("Search modules by name or description"); + fireEvent.change(filterInput, { target: { value: "token" } }); + + expect(screen.getByText("tokens")).toBeInTheDocument(); + expect(screen.queryByText("money")).not.toBeInTheDocument(); + expect(screen.queryByText("core")).not.toBeInTheDocument(); + }); +}); + +describe("Module detail pages with breadcrumbs (#126)", () => { + it("opens the module detail view when a module card is activated", () => { + render(); + const card = screen.getByRole("button", { name: /view details for core module/i }); + fireEvent.click(card); + + expect(screen.getByRole("heading", { name: "core" })).toBeInTheDocument(); + // Detail description paragraph (distinct from the hero lede). + expect( + screen.getByText("Deterministic environments and addresses"), + ).toBeInTheDocument(); + }); + + it("renders breadcrumb navigation on the detail page", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /view details for ledger module/i })); + + const breadcrumb = screen.getByRole("navigation", { name: /breadcrumb/i }); + expect(breadcrumb).toBeInTheDocument(); + expect(within(breadcrumb).getByRole("link", { name: "Home" })).toBeInTheDocument(); + expect(within(breadcrumb).getByRole("link", { name: "Modules" })).toBeInTheDocument(); + expect(within(breadcrumb).getByText("ledger")).toBeInTheDocument(); + }); + + it("navigates back to the module list via the breadcrumb link", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /view details for auth module/i })); + expect(screen.getByRole("heading", { name: "auth" })).toBeInTheDocument(); + + const breadcrumb = screen.getByRole("navigation", { name: /breadcrumb/i }); + fireEvent.click(within(breadcrumb).getByRole("link", { name: "Modules" })); + expect(screen.getByText("core")).toBeInTheDocument(); + expect(screen.getByText("ttl")).toBeInTheDocument(); + }); + + it("navigates back via the back button on the detail page", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /view details for ttl module/i })); + + fireEvent.click(screen.getByRole("button", { name: /back to all modules/i })); + expect(screen.getByText("events")).toBeInTheDocument(); + }); + + it("supports keyboard activation of module cards", () => { + render(); + const card = screen.getByRole("button", { name: /view details for money module/i }); + fireEvent.keyDown(card, { key: "Enter" }); + + expect(screen.getByRole("heading", { name: "money" })).toBeInTheDocument(); + }); +}); + +describe("Back-to-top control (#125)", () => { + it("renders a hidden back-to-top button initially", () => { + render(); + const btn = screen.getByLabelText(/back to top/i); + expect(btn).toBeInTheDocument(); + expect(btn).toHaveAttribute("aria-hidden", "true"); + expect(btn).toHaveAttribute("tabindex", "-1"); + }); + + it("shows the back-to-top button after scrolling down", () => { + render(); + Object.defineProperty(window, "scrollY", { value: 600, writable: true, configurable: true }); + fireEvent.scroll(window); + + const btn = screen.getByLabelText(/back to top/i); + expect(btn).toHaveAttribute("aria-hidden", "false"); + expect(btn).toHaveAttribute("tabindex", "0"); + expect(btn.className).toContain("visible"); + }); + + it("scrolls to top when the button is activated", () => { + const scrollSpy = vi.fn(); + window.scrollTo = scrollSpy; + + render(); + Object.defineProperty(window, "scrollY", { value: 800, writable: true, configurable: true }); + fireEvent.scroll(window); + + fireEvent.click(screen.getByLabelText(/back to top/i)); + expect(scrollSpy).toHaveBeenCalledWith({ top: 0, behavior: "smooth" }); + }); +}); + +describe("Module search keyboard shortcut (#127)", () => { + it("focuses the module search field when / is pressed", () => { + render(); + const filterInput = screen.getByLabelText("Search modules by name or description"); + + fireEvent.keyDown(document.body, { key: "/" }); + + expect(filterInput).toHaveFocus(); + }); + + it("does not trigger the shortcut when typing / inside the search input", () => { + render(); + const filterInput = screen.getByLabelText("Search modules by name or description") as HTMLInputElement; + + fireEvent.keyDown(filterInput, { key: "/" }); + + // The slash should not cause a side-effect (focus jump); the input simply keeps its value. + expect(filterInput).toBeInTheDocument(); + }); +}); + describe("Reduced-width layout for navigation labels (#189)", () => { it("renders accessible navigation links with responsive container attributes", () => { render(); diff --git a/src/App.tsx b/src/App.tsx index d49a947..6f2902a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,11 @@ -import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; +import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildCommand, formatCommandLine, modules, + moduleCategories, type CommandName, + type ModuleCategory, type OptionalArgKey, } from "./testkit"; import { copyToClipboard } from "./clipboard"; @@ -201,6 +203,9 @@ export default function App() { const [copied, setCopied] = useState(false); const [copiedStep, setCopiedStep] = useState(null); const [moduleFilter, setModuleFilter] = useState(""); + const [categoryFilter, setCategoryFilter] = useState(null); + const [selectedModule, setSelectedModule] = useState(null); + const [showBackToTop, setShowBackToTop] = useState(false); const [coverageOrder, setCoverageOrder] = useState(defaultCoverageOrder); const [multilineEditor, setMultilineEditor] = useState(false); const [advancedCommand, setAdvancedCommand] = useState(""); @@ -213,7 +218,14 @@ export default function App() { useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { - if (!event.ctrlKey && !event.metaKey) return; + if (!event.ctrlKey && !event.metaKey) { + // "/" focuses the module search field (#127) + if (event.key === "/" && !["INPUT", "TEXTAREA", "SELECT"].includes((event.target as HTMLElement)?.tagName ?? "")) { + event.preventDefault(); + document.getElementById("module-search")?.focus(); + } + return; + } switch (event.key) { case "1": @@ -277,12 +289,29 @@ export default function App() { const filteredModules = useMemo(() => { const q = moduleFilter.trim().toLowerCase(); - if (!q) return modules; - return modules.filter( - ([name, description]) => - name.toLowerCase().includes(q) || description.toLowerCase().includes(q) - ); - }, [moduleFilter]); + return modules.filter((m) => { + if (categoryFilter && m.category !== categoryFilter) return false; + if (!q) return true; + return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q); + }); + }, [moduleFilter, categoryFilter]); + + const selectedModuleData = useMemo( + () => (selectedModule ? modules.find((m) => m.name === selectedModule) ?? null : null), + [selectedModule], + ); + + // Back-to-top visibility after long scrolling (#125) + useEffect(() => { + const onScroll = () => setShowBackToTop(window.scrollY > 480); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + const scrollToTop = useCallback(() => { + window.scrollTo({ top: 0, behavior: "smooth" }); + document.getElementById("top")?.focus?.(); + }, []); const moveOptionalArg = (key: OptionalArgKey, direction: "up" | "down") => { setCoverageOrder((current) => { @@ -486,60 +515,167 @@ export default function App() {
-
-

One prelude. Seven focused modules.

-

Everything a serious contract test needs.

-
- - setModuleFilter(event.target.value)} - aria-label="Filter modules" - /> - {moduleFilter && ( + {selectedModuleData ? ( + /* Module detail view with breadcrumbs (#126) */ +
+ +
+

{selectedModuleData.category} module

+

{selectedModuleData.name}

+
+
+

{selectedModuleData.description}

+

+ Import {selectedModuleData.name} from the{" "} + soroban_testkit prelude in your contract tests. See the{" "} + + testkit-blockchain + {" "} + repository for full API documentation and examples. +

- )} -
-
- - {filteredModules.length > 0 ? ( -
- {filteredModules.map(([name, description], index) => ( -
- 0{index + 1} -

{name}

-

{description}

-
- ))} -
- ) : ( -
- -

No modules match “{moduleFilter}”

-

No testing modules found matching your filter criteria. Try adjusting your search term.

-
+ ) : ( + <> +
+

One prelude. Seven focused modules.

+

Everything a serious contract test needs.

+
+ + setModuleFilter(event.target.value)} + aria-label="Search modules by name or description" + /> + {moduleFilter && ( + + )} +
+
+ + {moduleCategories.map((cat) => ( + + ))} +
+
+ + {filteredModules.length > 0 ? ( +
+ {filteredModules.map((mod, index) => ( +
setSelectedModule(mod.name)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedModule(mod.name); + } + }} + > + 0{index + 1} +

{mod.name}

+

{mod.description}

+ {mod.category} +
+ ))} +
+ ) : ( +
+ +

No modules match your filters

+

+ {moduleFilter + ? `No testing modules found matching “${moduleFilter}”${categoryFilter ? ` in ${categoryFilter}` : ""}.` + : `No testing modules in the ${categoryFilter} category.`} + {" "}Try adjusting your filters. +

+ +
+ )} + )}
@@ -1184,6 +1320,18 @@ export default function App() { Built for the Stellar developer ecosystem. Apache-2.0 + + {/* Back-to-top control (#125) */} + ); } diff --git a/src/styles.css b/src/styles.css index 7acba87..3d45ae3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -273,6 +273,128 @@ h2 { margin: var(--space-md) 0; font-size: clamp(2rem, 4vw, 4rem); letter-spacin .module-empty-state p { margin: 0 0 var(--space-xl); color: var(--color-text-secondary); font-size: .9rem; line-height: 1.6; } .module-empty-state button { display: inline-block; } +/* Category filters (#124) */ +.category-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} +.category-chip { + padding: 6px 14px; + border-radius: 999px; + border: 1px solid var(--color-border-default); + background: var(--color-surface-card); + color: var(--color-text-secondary); + font-size: .8rem; + cursor: pointer; + transition: background .15s, color .15s, border-color .15s; +} +.category-chip:hover { border-color: var(--color-accent); color: var(--color-accent); } +.category-chip.active { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-canvas, #0c1a17); +} +.category-chip:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* Module cards (#124/#126) */ +.module-card { + cursor: pointer; + position: relative; +} +.module-card:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: -2px; +} +.module-category-tag { + display: inline-block; + margin-top: 12px; + padding: 2px 8px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + color: var(--color-text-muted); + font-size: .7rem; + font-family: var(--font-mono); +} + +/* Module detail + breadcrumbs (#126) */ +.module-detail { max-width: 760px; } +.breadcrumb ol { + display: flex; + flex-wrap: wrap; + gap: 6px; + list-style: none; + padding: 0; + margin: 0 0 20px; + font-size: .85rem; +} +.breadcrumb li { display: flex; align-items: center; gap: 6px; color: var(--color-text-muted); } +.breadcrumb li:not(:last-child)::after { content: "/"; color: var(--color-text-muted); opacity: .5; } +.breadcrumb a { + color: var(--color-accent); + text-decoration: none; +} +.breadcrumb a:hover { text-decoration: underline; } +.breadcrumb a:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} +.breadcrumb [aria-current="page"] { color: var(--color-text-primary); font-weight: 500; } +.module-detail-body { + background: var(--color-surface-card); + border: 1px solid var(--color-border-default); + border-radius: 8px; + padding: 28px; +} +.module-detail-description { + font-size: 1.1rem; + color: var(--color-text-primary); + margin: 0 0 16px; +} +.module-detail-body p { line-height: 1.7; color: var(--color-text-body); } +.module-detail-body code { + background: rgba(255, 255, 255, 0.06); + padding: 1px 6px; + border-radius: 4px; + font-family: var(--font-mono); + font-size: .9em; +} +.module-detail-body .button { margin-top: 18px; } + +/* Back-to-top control (#125) */ +.back-to-top { + position: fixed; + bottom: 28px; + right: 28px; + padding: 10px 18px; + border-radius: 999px; + border: 1px solid var(--color-accent); + background: var(--color-surface-card); + color: var(--color-accent); + font-size: .85rem; + font-weight: 500; + cursor: pointer; + opacity: 0; + pointer-events: none; + transform: translateY(8px); + transition: opacity .25s, transform .25s, background .15s; + z-index: 100; +} +.back-to-top.visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} +.back-to-top:hover { background: var(--color-accent); color: var(--color-canvas, #0c1a17); } +.back-to-top:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + /* Commands / CLI Workbench */ .docs, .commands { padding: var(--space-section) var(--page-padding); border-top: 1px solid var(--color-border-subtle); } .docs { background: rgba(12, 26, 23, 0.82); } diff --git a/src/testkit.ts b/src/testkit.ts index f61afe0..4169438 100644 --- a/src/testkit.ts +++ b/src/testkit.ts @@ -88,16 +88,30 @@ export const configPresets: ConfigPreset[] = [ }, ]; -export const modules = [ - ["core", "Deterministic environments and addresses"], - ["ledger", "Time, sequence, and clock control"], - ["money", "Adversarial values and conservation checks"], - ["events", "Typed capture and event assertions"], - ["tokens", "Stellar Asset Contract test doubles"], - ["auth", "Privilege and authorization matrices"], - ["ttl", "Storage lifetime and expiry simulation"], +export const moduleCategories = [ + "Environment", + "Economics", + "Assertions", + "Security", + "Storage", ] as const; +export type ModuleCategory = (typeof moduleCategories)[number]; + +export const modules: ReadonlyArray<{ + name: string; + description: string; + category: ModuleCategory; +}> = [ + { name: "core", description: "Deterministic environments and addresses", category: "Environment" }, + { name: "ledger", description: "Time, sequence, and clock control", category: "Environment" }, + { name: "money", description: "Adversarial values and conservation checks", category: "Economics" }, + { name: "events", description: "Typed capture and event assertions", category: "Assertions" }, + { name: "tokens", description: "Stellar Asset Contract test doubles", category: "Economics" }, + { name: "auth", description: "Privilege and authorization matrices", category: "Security" }, + { name: "ttl", description: "Storage lifetime and expiry simulation", category: "Storage" }, +]; + const quote = (value: string) => (value.includes(" ") ? `"${value}"` : value); const getOptionalArgOrder = (options: CommandOptions): OptionalArgKey[] => {