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);