diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/EPSearchEmpty.tsx b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/EPSearchEmpty.tsx new file mode 100644 index 000000000..4e12f62c5 --- /dev/null +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/EPSearchEmpty.tsx @@ -0,0 +1,171 @@ +/** + * EPSearchEmpty — no-results state. + * + * Renders only when an InstantSearch response has landed and reports zero + * hits (`useInstantSearch().results !== null && results.nbHits === 0`). + * That gate is deliberately stricter than `useStats().nbHits === 0`: it + * stays silent during the SSR-to-first-response window and during error + * states (where `results` keeps the last successful value), so designers + * never see Empty flash on a fresh page or hide a real error behind a + * "no results" message. + * + * The wrapper is `
`. `role="status"` + * is implicitly an `aria-live="polite"` region per the ARIA spec, so screen + * readers announce the slot's content when results flip from N hits to 0. + * + * Sibling visibility (e.g. hiding `EPSearchStats` / `EPSearchPagination` + * when results are empty) is the designer's call. Wire `dataCond: + * $ctx.searchStatsData.nbHits > 0` on those siblings — Empty stays + * single-purpose and does not publish a redundant `searchIsEmpty` flag. + * + * Composition split (per L7 from issue #304): + * `EPSearchEmpty` — top-level dispatcher (mock vs runtime branch). + * `MockEPSearchEmpty` — canvas branch; renders the wrapper unconditionally. + * `EPSearchEmptyInner` — runtime branch; calls `useInstantSearch()`. + * + * The two paths render the *same* JSX wrapper, so the headless-styling + * contract test sees structurally equivalent leaves in editor vs runtime. + */ + +import { usePlasmicCanvasContext } from "@plasmicapp/host"; +import registerComponent, { + CodeComponentMeta, +} from "@plasmicapp/host/registerComponent"; +import React from "react"; +import { Registerable } from "../registerable"; + +type PreviewState = "auto" | "withData"; + +interface EPSearchEmptyProps { + children?: React.ReactNode; + className?: string; + previewState?: PreviewState; +} + +export const epSearchEmptyMeta: CodeComponentMeta = { + name: "plasmic-commerce-ep-search-empty", + displayName: "EP Search Empty", + description: + "Shown when a search returns zero results. Stays hidden during the initial load and during error states; only renders after a real response with no hits. Wire `dataCond: $ctx.searchStatsData.nbHits > 0` on EP Search Stats / Pagination if you want them hidden alongside it. Must be inside EP Catalog Search Provider.", + props: { + children: { + type: "slot", + defaultValue: [ + { + type: "vbox", + styles: { + alignItems: "center", + gap: "8px", + width: "stretch", + }, + children: [ + { + type: "text", + tag: "h2", + value: "No results found", + styles: { + fontSize: "20px", + fontWeight: 600, + }, + }, + { + type: "text", + tag: "p", + value: + "Try clearing your filters or searching for something else.", + styles: { + fontSize: "14px", + color: "#6b7280", + }, + }, + ], + }, + ], + }, + previewState: { + type: "choice", + options: ["auto", "withData"], + defaultValue: "auto", + displayName: "Preview State", + description: + "`auto` renders in canvas and on real zero-result responses at runtime. `withData` pins Empty visible at runtime regardless of result count — useful for previewing the empty state while non-empty data is loaded.", + advanced: true, + }, + }, + importPath: "@elasticpath/plasmic-ep-commerce-elastic-path", + importName: "EPSearchEmpty", + parentComponentName: "plasmic-commerce-ep-catalog-search-provider", +}; + +export function EPSearchEmpty(props: EPSearchEmptyProps) { + const { children, className, previewState = "auto" } = props; + + const inEditor = !!usePlasmicCanvasContext(); + const useMock = + previewState === "withData" || (previewState === "auto" && inEditor); + + if (useMock) { + return ( + {children} + ); + } + + return ( + {children} + ); +} + +function MockEPSearchEmpty({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function EPSearchEmptyInner({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) { + const { useInstantSearch } = require("react-instantsearch"); + const { results } = useInstantSearch(); + + // Stay silent during the SSR-to-first-response window (results === null) + // and during error states (results keeps last successful value, which + // could have nbHits > 0). Only render on a real zero-hit response. + if (!results || results.nbHits !== 0) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +export function registerEPSearchEmpty( + loader?: Registerable, + customMeta?: CodeComponentMeta +) { + const doRegisterComponent: typeof registerComponent = (...args) => + loader ? loader.registerComponent(...args) : registerComponent(...args); + doRegisterComponent(EPSearchEmpty, customMeta ?? epSearchEmptyMeta); +} diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/catalog-search-components.test.tsx b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/catalog-search-components.test.tsx index 459539dff..26f3e28db 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/catalog-search-components.test.tsx +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/catalog-search-components.test.tsx @@ -238,6 +238,12 @@ const { registerEPCurrentRefinements, } = require("../EPCurrentRefinements") as typeof import("../EPCurrentRefinements"); +const { + EPSearchEmpty, + epSearchEmptyMeta, + registerEPSearchEmpty, +} = require("../EPSearchEmpty") as typeof import("../EPSearchEmpty"); + /* ---------- helpers ---------- */ const mockClient = { baseUrl: "https://api.test.com" }; const mockProvider = { locale: "en-US", client: mockClient }; @@ -1416,6 +1422,205 @@ describe("EPCurrentRefinements", () => { }); }); +/* ================================================================ + * EPSearchEmpty — no-results state + * ================================================================ */ +describe("EPSearchEmpty", () => { + it("meta enforces parentComponentName, name, importName", () => { + expect(epSearchEmptyMeta.name).toBe("plasmic-commerce-ep-search-empty"); + expect(epSearchEmptyMeta.parentComponentName).toBe( + "plasmic-commerce-ep-catalog-search-provider" + ); + expect(epSearchEmptyMeta.importName).toBe("EPSearchEmpty"); + }); + + it("runtime renders nothing when results === null (initial / pre-response)", () => { + setEditorMode(false); + mockUseInstantSearch.mockReturnValue({ results: null }); + + const { container } = render( + +
empty
+
+ ); + + expect( + container.querySelector('[data-testid="empty-content"]') + ).toBeNull(); + expect(container.querySelector("[data-ep-search-empty]")).toBeNull(); + }); + + it("runtime renders nothing when results.nbHits > 0", () => { + setEditorMode(false); + mockUseInstantSearch.mockReturnValue({ results: { nbHits: 5 } }); + + const { container } = render( + +
empty
+
+ ); + + expect( + container.querySelector('[data-testid="empty-content"]') + ).toBeNull(); + }); + + it("runtime renders the wrapper + slot when results !== null && nbHits === 0", () => { + setEditorMode(false); + mockUseInstantSearch.mockReturnValue({ results: { nbHits: 0 } }); + + const { container } = render( + +
empty
+
+ ); + + expect( + container.querySelector('[data-testid="empty-content"]') + ).not.toBeNull(); + expect(container.querySelector("[data-ep-search-empty]")).not.toBeNull(); + }); + + it("wrapper carries role='status' for assistive-tech announcement", () => { + setEditorMode(false); + mockUseInstantSearch.mockReturnValue({ results: { nbHits: 0 } }); + + const { container } = render( + +
empty
+
+ ); + + const wrapper = container.querySelector("[data-ep-search-empty]"); + expect(wrapper?.getAttribute("role")).toBe("status"); + }); + + it("editor mode renders the slot unconditionally (auto + inEditor)", () => { + setEditorMode(true); + // Even with results === null, the editor branch should render so + // designers see their layout. + mockUseInstantSearch.mockReturnValue({ results: null }); + + const { container } = render( + +
empty
+
+ ); + + expect( + container.querySelector('[data-testid="empty-content"]') + ).not.toBeNull(); + expect(container.querySelector("[data-ep-search-empty]")).not.toBeNull(); + }); + + it("editor branch does not call useInstantSearch (Mock + Inner split keeps the hook out of canvas)", () => { + setEditorMode(true); + mockUseInstantSearch.mockClear(); + + render( + +
empty
+
+ ); + + expect(mockUseInstantSearch).not.toHaveBeenCalled(); + }); + + it("previewState='withData' forces the wrapper to render at runtime regardless of result count", () => { + setEditorMode(false); + mockUseInstantSearch.mockReturnValue({ results: { nbHits: 12 } }); + + const { container } = render( + +
empty
+
+ ); + + expect( + container.querySelector('[data-testid="empty-content"]') + ).not.toBeNull(); + expect(container.querySelector("[data-ep-search-empty]")).not.toBeNull(); + }); + + it("results flipping from non-zero to zero mounts Empty (regression: live filtering into nothing)", () => { + setEditorMode(false); + mockUseInstantSearch.mockReturnValue({ results: { nbHits: 5 } }); + + const { container, rerender } = render( + +
empty
+
+ ); + + expect( + container.querySelector('[data-testid="empty-content"]') + ).toBeNull(); + + mockUseInstantSearch.mockReturnValue({ results: { nbHits: 0 } }); + rerender( + +
empty
+
+ ); + + expect( + container.querySelector('[data-testid="empty-content"]') + ).not.toBeNull(); + }); + + it("results flipping from zero to non-zero unmounts Empty cleanly", () => { + setEditorMode(false); + mockUseInstantSearch.mockReturnValue({ results: { nbHits: 0 } }); + + const { container, rerender } = render( + +
empty
+
+ ); + + expect( + container.querySelector('[data-testid="empty-content"]') + ).not.toBeNull(); + + mockUseInstantSearch.mockReturnValue({ results: { nbHits: 5 } }); + rerender( + +
empty
+
+ ); + + expect( + container.querySelector('[data-testid="empty-content"]') + ).toBeNull(); + expect(container.querySelector("[data-ep-search-empty]")).toBeNull(); + }); + + it("default slot tree ships heading + body copy with correct tags", () => { + const slot = (epSearchEmptyMeta.props as any).children.defaultValue; + expect(Array.isArray(slot)).toBe(true); + const vbox = slot[0]; + expect(vbox.type).toBe("vbox"); + const [heading, body] = vbox.children; + expect(heading.tag).toBe("h2"); + expect(heading.value).toBe("No results found"); + expect(body.tag).toBe("p"); + expect(body.value).toBe( + "Try clearing your filters or searching for something else." + ); + }); + + it("registerEPSearchEmpty calls registerComponent", () => { + const registerComponent = require("@plasmicapp/host/registerComponent") + .default; + registerComponent.mockClear(); + registerEPSearchEmpty(); + expect(registerComponent).toHaveBeenCalledWith( + EPSearchEmpty, + epSearchEmptyMeta + ); + }); +}); + /* ================================================================ * Component registration tests * ================================================================ */ @@ -1792,3 +1997,22 @@ describeHeadlessStylingContract({ ); }, }); + +describeHeadlessStylingContract({ + componentName: "EPSearchEmpty", + leafSelector: "[data-ep-search-empty]", + setEditorMode, + renderInEditor: ({ className }) => ( + +
empty
+
+ ), + renderAtRuntime: ({ className }) => { + mockUseInstantSearch.mockReturnValue({ results: { nbHits: 0 } }); + return ( + +
empty
+
+ ); + }, +}); diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/headless-styling.ts b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/headless-styling.ts index 86c7a4a34..38e0472fd 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/headless-styling.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/headless-styling.ts @@ -30,6 +30,11 @@ const STYLE_BLOCK = ` flex-wrap: wrap; gap: 8px; } +:where([data-ep-search-empty]) { + width: 100%; + align-self: stretch; + text-align: center; +} :where([data-ep-autocomplete-root]) { position: relative; width: 100%; diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/index.ts b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/index.ts index f6032dac1..1127f459e 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/index.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/index.ts @@ -27,6 +27,7 @@ export { EPCurrentRefinements, registerEPCurrentRefinements, } from "./EPCurrentRefinements"; +export { EPSearchEmpty, registerEPSearchEmpty } from "./EPSearchEmpty"; export { EPSearchAutocomplete, registerEPSearchAutocomplete, diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/index.tsx b/plasmicpkgs/commerce-providers/elastic-path/src/index.tsx index 1ff5a4d02..cc5ee6c62 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/index.tsx +++ b/plasmicpkgs/commerce-providers/elastic-path/src/index.tsx @@ -53,6 +53,7 @@ import { registerEPSearchStats } from "./catalog-search/EPSearchStats"; import { registerEPSearchSortBy } from "./catalog-search/EPSearchSortBy"; import { registerEPClearRefinements } from "./catalog-search/EPClearRefinements"; import { registerEPCurrentRefinements } from "./catalog-search/EPCurrentRefinements"; +import { registerEPSearchEmpty } from "./catalog-search/EPSearchEmpty"; import { registerEPSearchAutocomplete } from "./catalog-search/EPSearchAutocomplete"; import { registerEPSearchAutocompleteInput } from "./catalog-search/EPSearchAutocompleteInput"; import { registerEPSearchAutocompletePanel } from "./catalog-search/EPSearchAutocompletePanel"; @@ -163,6 +164,7 @@ export function registerAll(loader?: Registerable) { registerEPSearchPagination(loader); registerEPClearRefinements(loader); registerEPCurrentRefinements(loader); + registerEPSearchEmpty(loader); registerEPSearchAutocomplete(loader); registerEPSearchAutocompleteInput(loader); registerEPSearchAutocompletePanel(loader);