-
-
+
+ {/* Categories Group Skeleton */}
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+ {/* Price Range Group Skeleton */}
+
+
+
+
+
- ))}
-
-
+
+
+
+
+
+
+ {/* Status Group Skeleton */}
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+ {/* Popularity Group Skeleton */}
+
+
+
+
+
+ {/* Favorites Group Skeleton */}
+
+
+ {/* Clear Filters Button Skeleton */}
+
);
}
@@ -247,80 +291,8 @@ export function EmptyStateSkeleton({ size = "default", hasAction = false }: { si
);
}
-/**
- * MarketplacePageSkeleton — loading placeholder for the marketplace page.
- * Renders a simplified layout shell while API data is being fetched.
- */
-export function MarketplacePageSkeleton() {
- return (
-
- {/* Toolbar skeleton */}
-
-
-
-
-
- {/* Category pills skeleton */}
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
-
- {/* Tag filter skeleton */}
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
-
- {/* Grid skeleton */}
-
- {Array.from({ length: 6 }).map((_, i) => (
-
-
-
- {Array.from({ length: 3 }).map((_, j) => (
-
- ))}
-
-
-
- ))}
-
-
- );
-}
+import MarketplacePageSkeleton from "../pages/MarketplacePage.skeleton";
+export { MarketplacePageSkeleton };
export function ApiDetailPageSkeleton({ onBack }: { onBack?: () => void }) {
return (
diff --git a/src/pages/MarketplacePage.skeleton.test.tsx b/src/pages/MarketplacePage.skeleton.test.tsx
index ce98f9c..1f24242 100644
--- a/src/pages/MarketplacePage.skeleton.test.tsx
+++ b/src/pages/MarketplacePage.skeleton.test.tsx
@@ -11,32 +11,48 @@ describe("MarketplacePageSkeleton", () => {
expect(
getByLabelText("Marketplace loading shell").getAttribute("aria-busy"),
).toBe("true");
+ expect(container.querySelector(".marketplace-page")).toBeTruthy();
+ expect(container.querySelector(".marketplace-header")).toBeTruthy();
+ expect(container.querySelector(".recently-active-rail-skeleton")).toBeTruthy();
expect(container.querySelector(".marketplace-layout")).toBeTruthy();
expect(container.querySelector(".marketplace-sidebar")).toBeTruthy();
+ expect(container.querySelector(".marketplace-toolbar")).toBeTruthy();
+ expect(container.querySelector(".filters-sidebar")).toBeTruthy();
+ expect(container.querySelector(".pill-bar")).toBeTruthy();
+ expect(container.querySelector(".api-tag-filter")).toBeTruthy();
expect(container.querySelector(".marketplace-grid")).toBeTruthy();
- expect(container.querySelectorAll(".api-marketplace-card").length).toBe(6);
+ expect(container.querySelectorAll(".api-marketplace-card").length).toBe(12);
});
it("renders card skeletons that match the ApiCard structure for shape parity", () => {
const { container } = render(
);
- // Each card skeleton should contain the stats section matching the real card
const cards = container.querySelectorAll(".api-marketplace-card");
- expect(cards.length).toBe(6);
+ expect(cards.length).toBe(12);
- // Each card should have a stats section (matching real ApiCard structure)
cards.forEach((card) => {
expect(card.querySelector(".api-card__stats")).toBeTruthy();
expect(card.querySelectorAll(".api-card__stat").length).toBe(3);
});
});
- it("renders header, filter sidebar, and toolbar placeholders", () => {
+ it("supports compact density prop", () => {
+ const { container } = render(
);
+
+ const cards = container.querySelectorAll(".api-marketplace-card");
+ expect(cards.length).toBe(12);
+ cards.forEach((card) => {
+ expect(card.classList.contains("api-card--compact")).toBe(true);
+ });
+ });
+
+ it("decorates inner skeleton elements with aria-hidden for accessibility", () => {
const { container } = render(
);
- expect(container.querySelector(".marketplace-header")).toBeTruthy();
- expect(container.querySelector(".marketplace-sidebar")).toBeTruthy();
- expect(container.querySelector(".marketplace-toolbar")).toBeTruthy();
- expect(container.querySelector(".filters-sidebar")).toBeTruthy();
+ const rail = container.querySelector(".recently-active-rail-skeleton");
+ expect(rail?.getAttribute("aria-hidden")).toBe("true");
+
+ const sidebar = container.querySelector(".filters-sidebar-skeleton");
+ expect(sidebar?.getAttribute("aria-hidden")).toBe("true");
});
});
diff --git a/src/pages/MarketplacePage.skeleton.tsx b/src/pages/MarketplacePage.skeleton.tsx
index 7fcd1fc..c3de60c 100644
--- a/src/pages/MarketplacePage.skeleton.tsx
+++ b/src/pages/MarketplacePage.skeleton.tsx
@@ -2,16 +2,23 @@
* MarketplacePage.skeleton.tsx
*
* Loading shell for the Marketplace page.
- * Uses ApiCardSkeleton (from ApiCard.tsx) to keep skeleton shapes in
- * exact parity with the real cards — dimensions, layout structure, and
- * spacing all mirror the final rendered output so the CLS shift on load
- * is minimal.
+ * Mirrors the exact layout, spacing, components, and responsive grid structure of
+ * MarketplacePage to ensure minimal cumulative layout shift (CLS) on data hydration.
+ *
+ * Accessibility (WCAG 2.1 AA):
+ * - Container carries aria-busy="true" and aria-label="Marketplace loading shell".
+ * - Inner placeholders carry aria-hidden="true" to avoid screen-reader noise.
*/
-import Skeleton from "../components/Skeleton";
+import Skeleton, { FiltersSidebarSkeleton } from "../components/Skeleton";
import { ApiCardSkeleton } from "../components/ApiCard";
-import { FiltersSidebarSkeleton } from "../components/Skeleton";
+import { ApiTagFilterSkeleton } from "./ApiTagFilter";
+import type { DensityPreference } from "../utils/density";
-export default function MarketplacePageSkeleton() {
+export default function MarketplacePageSkeleton({
+ density = "comfortable",
+}: {
+ density?: DensityPreference;
+}) {
return (
{/* ── Header row ── */}
- {/* Page title */}
-
-
- {/* Search + density toggle */}
+
API Marketplace
+
+
- {/* Sort dropdown */}
-
+ {/* ── Recently Active Rail Placeholder ── */}
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
{/* ── Main layout ── */}
diff --git a/src/pages/MarketplacePage.test.tsx b/src/pages/MarketplacePage.test.tsx
index a53a9b6..04fba7d 100644
--- a/src/pages/MarketplacePage.test.tsx
+++ b/src/pages/MarketplacePage.test.tsx
@@ -517,3 +517,54 @@ describe("MarketplacePage tabular-nums (FWC26)", () => {
}
});
});
+
+describe("MarketplacePage loading skeleton transition", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }),
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("renders MarketplacePageSkeleton while loading before timers settle", () => {
+ renderPage();
+
+ // Before timers settle, page should render MarketplacePageSkeleton
+ const loadingShell = screen.getByLabelText("Marketplace loading shell");
+ expect(loadingShell).toBeTruthy();
+ expect(loadingShell.getAttribute("aria-busy")).toBe("true");
+
+ const cards = document.querySelectorAll(".api-marketplace-card");
+ expect(cards.length).toBe(12);
+ });
+
+ it("transitions smoothly from MarketplacePageSkeleton to loaded content when timers settle", () => {
+ renderPage();
+
+ // Verify initial loading shell
+ expect(screen.getByLabelText("Marketplace loading shell")).toBeTruthy();
+
+ // Advance timers to complete loading
+ settleMarketplaceTimers();
+
+ // Verify loaded page elements
+ expect(screen.queryByLabelText("Marketplace loading shell")).toBeNull();
+ expect(screen.getByRole("heading", { name: "API Marketplace", level: 1 })).toBeTruthy();
+ expect(document.querySelector(".marketplace-grid")).toBeTruthy();
+ });
+});
diff --git a/src/pages/MarketplacePage.tsx b/src/pages/MarketplacePage.tsx
index f8c8ece..0f0810e 100644
--- a/src/pages/MarketplacePage.tsx
+++ b/src/pages/MarketplacePage.tsx
@@ -8,7 +8,7 @@ import SortDropdown, { type SortValue } from "../components/SortDropdown";
import CategoryPills from "../components/CategoryPills";
import ApiTagFilter, { getAllUniqueTags } from "./ApiTagFilter";
-import FiltersSidebar, { ALL_CATEGORIES, STATUS_OPTIONS } from "../components/FiltersSidebar";
+import FiltersSidebar, { ALL_CATEGORIES } from "../components/FiltersSidebar";
import KbdHint from "../components/KbdHint";
import { SHORTCUTS } from "../hooks/useGlobalShortcuts";
import EmptyState from "../components/EmptyState";
@@ -22,7 +22,7 @@ import {
persistDensityPreference,
type DensityPreference,
} from "../utils/density";
-import CompareDrawer from "../components/CompareDrawer";
+
import FiltersBottomSheet from "../components/FiltersBottomSheet";
import LiveRegion from "../components/LiveRegion";
import RecentlyActiveRail from "../components/RecentlyActiveRail";
@@ -498,6 +498,10 @@ export default function MarketplacePage(): JSX.Element {
const startItem = (validCurrentPage - 1) * pageSize + 1;
const endItem = Math.min(validCurrentPage * pageSize, filtered.length);
+ if (isLoading) {
+ return
;
+ }
+
return (
{/* Top row: title + search only */}
@@ -642,8 +646,6 @@ export default function MarketplacePage(): JSX.Element {
{fetchError ? (
- ) : isLoading ? (
-
) : filtered.length === 0 ? (