From e69cb6527d05d35032791440484911cf27894b89 Mon Sep 17 00:00:00 2001 From: Mekjah Date: Tue, 28 Jul 2026 20:47:04 +0100 Subject: [PATCH] feat(filters): add themed empty state for FiltersSidebar --- docs/UI-Design-System.md | 30 +++++++++++------- src/components/EmptyState.tsx | 2 +- src/components/FiltersSidebar.test.tsx | 44 ++++++++++++++++++++++++++ src/components/FiltersSidebar.tsx | 5 ++- src/index.css | 1 - 5 files changed, 65 insertions(+), 17 deletions(-) diff --git a/docs/UI-Design-System.md b/docs/UI-Design-System.md index ff695c3..670420b 100644 --- a/docs/UI-Design-System.md +++ b/docs/UI-Design-System.md @@ -307,15 +307,19 @@ size tailored specifically for inline use inside FiltersSidebar. **Props:** -| Prop | Type | Default | Description | -| ----------------- | ---------------------------------------- | ----------- | ------------------------------------------------------------------------- | -| `variant?` | `"empty" \| "filtered" \| "error"` | `"empty"` | Which semantic state to render. | -| `size?` | `"default" \| "compact"` | `"default"` | Full-size (marketplace results) vs condensed (FiltersSidebar inline). | -| `title?` | `string` | per variant | Override the default heading. | -| `message?` | `string` | per variant | Override the default subtitle. | -| `onClearFilters?` | `() => void` | — | Shown only when `variant === "filtered"`. Renders the Clear CTA. | -| `onRetry?` | `() => void \| Promise` | — | Shown only when `variant === "error"`. Handles async loading + aria-busy. | -| `action?` | `{ label: string; onClick: () => void }` | — | Optional custom CTA button rendered before any variant-specific actions. | +| Prop | Type | Default | Description | +| ----------------- | -------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------- | +| `variant?` | `"empty" \| "api-detail" \| "filtered" \| "error" \| "plan-badge" \| "risk-gauge" \| "quota-banner"` | `"empty"` | Which semantic state to render. | +| `size?` | `"default" \| "compact"` | `"default"` | Full-size (marketplace results) vs condensed (FiltersSidebar inline). | +| `title?` | `string` | per variant | Override the default heading. | +| `message?` | `string` | per variant | Override the default subtitle. | +| `description?` | `string` | — | Deprecated: use `message` instead. | +| `onClearFilters?` | `() => void` | — | Shown only when `variant === "filtered"`. Renders the Clear CTA. | +| `onRetry?` | `() => void \| Promise` | — | Shown only when `variant === "error"`. Handles async loading + aria-busy. | +| `action?` | `{ label: string; onClick: () => void }` | — | Optional custom CTA button rendered before any variant-specific actions. | +| `secondaryAction?`| `{ label: string; onClick: () => void }` | — | Optional secondary CTA button rendered after the primary action. | +| `loading?` | `boolean` | `false` | Renders the loading skeleton variant. | +| `copyable?` | `boolean` | `false` | When true, renders a copy-to-clipboard button for the message text. | **Visual Spec (v7):** @@ -561,7 +565,7 @@ Enhanced in v7 with an inline, context-aware **zero-results EmptyState** that ap | `clearFilters` | `() => void` | (req) | Reset every filter to its default/empty state. | | `favoritesOnly?` | `boolean` | `false` | Whether the "Favorites only" toggle is checked. | | `toggleFavoritesOnly?` | `() => void` | no-op | Toggle the `favoritesOnly` state. | -| `resultCount?` | `number` | — | **(v7)** Live count of results after filtering. When `0` + active filters, renders the inline EmptyState. | +| `resultCount?` | `number` | — | **(v7)** Live count of results after filtering. When `0`, renders the inline EmptyState (filtered if filters active, empty otherwise). | **Visual Spec:** @@ -570,7 +574,7 @@ Enhanced in v7 with an inline, context-aware **zero-results EmptyState** that ap - Price range: Two `` fields with a `WarningIcon` + error message when `min > max`. - Popularity: Accessible `Dropdown` component. - **Zero-results inline block (v7):** Wrapped in a `12px` padding-top + `1px solid var(--line)` top-border separator so it visually groups with "results feedback" rather than the Favorites controls above. Contains: - - `` + - `` - Wrapper carries `role="status"` + `aria-live="polite"` for assistive-tech announcements. - Clear button: Ghost button style, always present at the bottom. @@ -579,7 +583,9 @@ Enhanced in v7 with an inline, context-aware **zero-results EmptyState** that ap - Default: All filter sections expanded. - Collapsed: Section header shows rotated chevron; panel is `hidden`. - Price error: Both price inputs gain `filter-input--invalid` class; alert paragraph appears with `role="alert"`. -- **Zero-results (v7):** When `resultCount === 0` AND at least one filter is active (categories, price bounds, non-any popularity, or favorites-only), the inline EmptyState mounts with a token-based top-border separator above it. +- **Zero-results (v7):** When `resultCount === 0`: + - AND at least one filter is active → renders `variant="filtered"` with a "Clear filters" CTA. + - AND no filters are active → renders `variant="empty"` with the generic "No APIs available" message. - Focused: Standard focus ring on all inputs and buttons. **Responsive Behavior:** diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx index 0bf63d9..cc3f68f 100644 --- a/src/components/EmptyState.tsx +++ b/src/components/EmptyState.tsx @@ -641,7 +641,7 @@ export default function EmptyState({ }: EmptyStateProps) { const resolvedMessage = message ?? description; const resolvedAction = action; - const { copied, handleCopy } = useCopy(); + const { copy: handleCopy, copied } = useCopy(); if (loading) { return ( diff --git a/src/components/FiltersSidebar.test.tsx b/src/components/FiltersSidebar.test.tsx index 3be23e4..c79a787 100644 --- a/src/components/FiltersSidebar.test.tsx +++ b/src/components/FiltersSidebar.test.tsx @@ -391,6 +391,50 @@ describe("FiltersSidebar", () => { expect(within(dialog).getByTestId("filters-zero-results")).toBeTruthy(); expect(within(dialog).getByTestId("empty-state-filtered")).toBeTruthy(); }); + + it("renders EmptyState when resultCount=0 and no filters active (genuine empty marketplace)", () => { + render(); + const block = screen.getByTestId("filters-zero-results"); + expect(block).toBeTruthy(); + expect( + block.querySelector('[data-testid="empty-state-empty"]'), + ).toBeTruthy(); + }); + + it("does not render clear-filters CTA when no filters are active", () => { + render(); + expect(screen.queryByTestId("empty-state-clear-filters")).toBeNull(); + }); + + it("renders sidebar controls normally when resultCount > 0", () => { + render(); + expect(screen.getByText("Categories")).toBeTruthy(); + expect(screen.getByText("Price range")).toBeTruthy(); + expect(screen.queryByTestId("filters-zero-results")).toBeNull(); + }); + + it("renders EmptyState illustration for empty variant inside sidebar", () => { + render(); + const emptyState = screen.getByTestId("empty-state-empty"); + expect(emptyState.querySelector("svg")).toBeTruthy(); + expect(emptyState.getAttribute("data-size")).toBe("compact"); + }); + + it("uses compact size for both empty and filtered variants inside sidebar", () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId("empty-state-empty").getAttribute("data-size")).toBe("compact"); + + rerender( + , + ); + expect(screen.getByTestId("empty-state-filtered").getAttribute("data-size")).toBe("compact"); + }); }); describe("empty-state illustration (v7) visual refinements", () => { diff --git a/src/components/FiltersSidebar.tsx b/src/components/FiltersSidebar.tsx index fae3cd6..595de4c 100644 --- a/src/components/FiltersSidebar.tsx +++ b/src/components/FiltersSidebar.tsx @@ -494,8 +494,7 @@ export default function FiltersSidebar({ The wrapper is aria-live="polite" so screen readers announce the zero-results state when filters narrow the count to 0. */} {typeof resultCount === "number" && - resultCount === 0 && - hasActiveFilters && ( + resultCount === 0 && (
diff --git a/src/index.css b/src/index.css index 1b3ba58..221d2cf 100644 --- a/src/index.css +++ b/src/index.css @@ -2662,7 +2662,6 @@ code, font-size: 0.65rem; } } -} /* QuotaBanner layout and styles */ .quota-banner {