diff --git a/src/components/Popup/Popup.stories.tsx b/src/components/Popup/Popup.stories.tsx new file mode 100644 index 00000000..5c5a9981 --- /dev/null +++ b/src/components/Popup/Popup.stories.tsx @@ -0,0 +1,100 @@ +import type { StoryObj, Meta } from "@storybook/web-components" +import { html } from "lit" + +import "./Popup" + +const meta: Meta = { + title: "Components/Popup", + component: "nosto-popup", + argTypes: { + name: { + control: "text", + description: "Optional name used in analytics and localStorage for persistent closing" + }, + segment: { + control: "text", + description: "Optional Nosto segment that acts as a precondition for activation" + } + }, + parameters: { + docs: { + description: { + component: ` +A popup component that displays content in a dialog with optional ribbon content. +Supports segment-based activation and persistent closure state using localStorage. + ` + } + } + } +} + +export default meta +type Story = StoryObj + +// reset popular storage key for demo purposes +localStorage.clear() + +export const Default: Story = { + args: {}, + render: () => html` + +
+

Special Offer!

+

Get 20% off your next purchase when you sign up for our newsletter.

+ + + +
+
Hello from the ribbon!
+
+ ` +} + +export const WithRibbon: Story = { + args: {}, + render: () => html` + +
+

Limited Time Offer

+

Don't miss out on this exclusive deal!

+ + +
+
+ ⏰ Only 2 hours left! + +
+
+ ` +} diff --git a/src/components/Popup/Popup.ts b/src/components/Popup/Popup.ts new file mode 100644 index 00000000..bfe4caa7 --- /dev/null +++ b/src/components/Popup/Popup.ts @@ -0,0 +1,151 @@ +import { nostojs } from "@nosto/nosto-js" +import { customElement } from "../decorators" +import { NostoElement } from "../Element" +import { popupStyles } from "./styles" +import { assertRequired } from "@/utils/assertRequired" + +/** + * A custom element that displays popup content with dialog and ribbon slots. + * Supports conditional activation based on Nosto segments and persistent closure state. + * + * @property {string} name - Required name used for analytics and localStorage persistence. The popup's closed state will be remembered. + * @property {string} [segment] - Optional Nosto segment that acts as a precondition for activation. Only users in this segment will see the popup. + * + * @example + * Basic popup with dialog and ribbon content: + * ```html + * + *

Special Offer!

+ *

Get 20% off your order today

+ * + *
+ * Limited time! + *
+ *
+ * ``` + */ +@customElement("nosto-popup") +export class Popup extends NostoElement { + /** @private */ + static attributes = { + name: String, + segment: String + } + + name!: string + segment?: string + + constructor() { + super() + this.attachShadow({ mode: "open" }) + } + + async connectedCallback() { + assertRequired(this, "name") + const state = await getPopupState(this.name, this.segment) + if (state === "closed") { + this.style.display = "none" + return + } + if (!this.shadowRoot?.innerHTML) { + initializeShadowContent(this, state) + } + this.addEventListener("click", this.handleClick.bind(this)) + setPopupState(this.name, "ribbon") + } + + private handleClick(event: Event) { + const target = event.target as HTMLElement + const toClose = target?.matches("[n-close]") || target?.closest("[n-close]") + const toRibbon = target?.matches("[n-ribbon]") || target?.closest("[n-ribbon]") + const toOpen = target?.matches("[slot='ribbon']") || target?.closest("[slot='ribbon']") + console.log("Popup clicked:", target, { toOpen, toClose, toRibbon }) + + if (toOpen || toClose || toRibbon) { + event.preventDefault() + event.stopPropagation() + } + if (toClose) { + closePopup(this) + } else if (toOpen) { + updateShadowContent(this, "open") + } else if (toRibbon) { + setPopupState(this.name, "ribbon") + updateShadowContent(this, "ribbon") + } + } +} + +const key = "nosto:web-components:popup" + +type PopupState = "open" | "ribbon" | "closed" + +type PopupData = { + name: string + state: PopupState +} + +function initializeShadowContent(element: Popup, mode: "open" | "ribbon" = "open") { + element.shadowRoot!.innerHTML = ` + + + + +
+ Open +
+ ` + if (mode === "open") { + element.shadowRoot?.querySelector("dialog")?.showModal() + } +} + +function updateShadowContent(element: Popup, mode: "open" | "ribbon" = "open") { + const dialog = element.shadowRoot?.querySelector("dialog") + const ribbon = element.shadowRoot?.querySelector(".ribbon") + if (dialog && ribbon) { + if (mode === "ribbon") { + dialog.close() + ribbon.classList.remove("hidden") + } else { + dialog.showModal() + ribbon.classList.add("hidden") + } + } +} + +function closePopup(element: Popup) { + setPopupState(element.name, "closed") + element.style.display = "none" +} + +async function getPopupState(name: string, segment?: string): Promise { + if (segment && !(await checkSegment(segment))) { + return "closed" + } + const dataStr = localStorage.getItem(key) + if (dataStr) { + const data = JSON.parse(dataStr) as PopupData + if (data.name !== name) { + return "closed" + } + return data.state + } + return "open" +} + +function setPopupState(name: string, state: PopupState) { + localStorage.setItem(key, JSON.stringify({ name, state })) +} + +async function checkSegment(segment: string) { + const api = await new Promise(nostojs) + const segments = await api.internal.getSegments() + return segments?.includes(segment) || false +} + +declare global { + interface HTMLElementTagNameMap { + "nosto-popup": Popup + } +} diff --git a/src/components/Popup/styles.ts b/src/components/Popup/styles.ts new file mode 100644 index 00000000..1e4c6ee3 --- /dev/null +++ b/src/components/Popup/styles.ts @@ -0,0 +1,40 @@ +export const popupStyles = ` + :host { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; + pointer-events: none; + } + + dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border: none; + border-radius: 8px; + padding: 0; + background: transparent; + pointer-events: auto; + } + + dialog::backdrop { + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(2px); + } + + .ribbon { + position: fixed; + bottom: 20px; + right: 20px; + pointer-events: auto; + z-index: 1002; + } + + .hidden { + display: none; + } +` diff --git a/test/components/Popup/Popup.spec.tsx b/test/components/Popup/Popup.spec.tsx new file mode 100644 index 00000000..5916bfda --- /dev/null +++ b/test/components/Popup/Popup.spec.tsx @@ -0,0 +1,464 @@ +/** @jsx createElement */ +import { describe, beforeEach, afterEach, it, expect, vi } from "vitest" +import { Popup } from "@/components/Popup/Popup" +import { mockNostojs } from "@nosto/nosto-js/testing" +import { createElement } from "../../utils/jsx" + +describe("Popup", () => { + const popupKey = "nosto:web-components:popup" + + function getPopupData() { + return JSON.parse(localStorage.getItem(popupKey)!) + } + + function setPopupData(data: { name: string; state: "open" | "ribbon" | "closed" }) { + localStorage.setItem(popupKey, JSON.stringify(data)) + } + + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear() + + // Mock default successful segment check + mockNostojs({ + internal: { + getSegments: () => Promise.resolve(["matching-segment"]) + } + }) + }) + + afterEach(() => { + // Clean up any popups created during tests + document.querySelectorAll("nosto-popup").forEach(el => el.remove()) + }) + + describe("Basic functionality", () => { + it("should render shadow content with dialog and ribbon slots", async () => { + const popup = ( + +
Dialog content
+
Ribbon content
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + expect(popup.shadowRoot).toBeTruthy() + expect(popup.shadowRoot?.querySelector("dialog")).toBeTruthy() + expect(popup.shadowRoot?.querySelector(".ribbon")).toBeTruthy() + expect(popup.shadowRoot?.querySelector('slot[name="default"]')).toBeTruthy() + expect(popup.shadowRoot?.querySelector('slot[name="ribbon"]')).toBeTruthy() + }) + + it("should be visible by default", async () => { + const popup = ( + +
Content
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + expect(popup.style.display).not.toBe("none") + }) + }) + + describe("Named popups and persistence", () => { + it("should hide popup if it was previously closed and name is set", async () => { + const popupName = "test-popup" + setPopupData({ name: popupName, state: "closed" }) + + const popup = ( + +
Content
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + expect(popup.style.display).toBe("none") + }) + + it("should show popup if name is set but not previously closed", async () => { + const popup = ( + +
Content
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + expect(popup.style.display).not.toBe("none") + }) + + it("should throw error when name attribute is missing", async () => { + const popup = ( + +
Content
+
+ ) as Popup + + // Don't append to DOM to avoid automatic connectedCallback call + // that would cause unhandled error + await expect(popup.connectedCallback()).rejects.toThrow("Property name is required.") + }) + }) + + describe("Segment-based activation", () => { + it("should show popup when user has matching segment", async () => { + mockNostojs({ + internal: { + getSegments: () => Promise.resolve(["segment1", "target-segment", "segment3"]) + } + }) + + const popup = ( + +
Content
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + expect(popup.style.display).not.toBe("none") + }) + + it("should hide popup when user does not have matching segment", async () => { + mockNostojs({ + internal: { + getSegments: () => Promise.resolve(["segment1", "segment2", "segment3"]) + } + }) + + const popup = ( + +
Content
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + expect(popup.style.display).toBe("none") + }) + + it("should show popup when no segment attribute is specified", async () => { + const popup = ( + +
Content
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + expect(popup.style.display).not.toBe("none") + }) + + it("should propagate segment API errors", async () => { + mockNostojs({ + internal: { + getSegments: () => Promise.reject(new Error("API Error")) + } + }) + + const popup = ( + +
Content
+
+ ) as Popup + + // Don't append to DOM to avoid automatic connectedCallback call + // that would cause unhandled error + await expect(popup.connectedCallback()).rejects.toThrow("API Error") + }) + }) + + describe("Click handling and closing", () => { + it("should close popup when element with n-close attribute is clicked", async () => { + const popup = ( + +
+ +
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + expect(popup.style.display).not.toBe("none") + + const closeButton = popup.querySelector("[n-close]") as HTMLButtonElement + expect(closeButton).toBeTruthy() + + closeButton.click() + + expect(popup.style.display).toBe("none") + }) + + it("should store closed state in localStorage when popup has name", async () => { + const popupName = "persistent-popup" + const popup = ( + +
+ +
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + const closeButton = popup.querySelector("[n-close]") as HTMLButtonElement + closeButton.click() + + expect(getPopupData()).toEqual({ name: popupName, state: "closed" }) + }) + + it("should always store closed state in localStorage since name is required", async () => { + const popup = ( + +
+ +
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + const closeButton = popup.querySelector("[n-close]") as HTMLButtonElement + closeButton.click() + + expect(getPopupData()).toEqual({ name: "always-stores-popup", state: "closed" }) + }) + + it("should handle click events on ribbon content with n-close", async () => { + const popup = ( + +
Dialog content
+
+ Limited time! + +
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + const ribbonCloseButton = popup.querySelector('[slot="ribbon"] [n-close]') as HTMLButtonElement + expect(ribbonCloseButton).toBeTruthy() + + ribbonCloseButton.click() + + expect(popup.style.display).toBe("none") + expect(getPopupData()).toEqual({ name: "ribbon-popup", state: "closed" }) + }) + + it("should not close popup when clicking elements without n-close attribute", async () => { + const popup = ( + +
+

Some content

+ +
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + const regularButton = popup.querySelector("button:not([n-close])") as HTMLButtonElement + expect(regularButton).toBeTruthy() + + regularButton.click() + + expect(popup.style.display).not.toBe("none") + }) + + it("should prevent default and stop propagation on n-close click", async () => { + const popup = ( + +
+ + Close link + +
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + const closeLink = popup.querySelector("[n-close]") as HTMLAnchorElement + + const clickEvent = new Event("click", { bubbles: true, cancelable: true }) + const preventDefaultSpy = vi.spyOn(clickEvent, "preventDefault") + const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation") + + closeLink.dispatchEvent(clickEvent) + + expect(preventDefaultSpy).toHaveBeenCalled() + expect(stopPropagationSpy).toHaveBeenCalled() + expect(popup.style.display).toBe("none") + }) + + it("should close popup when clicking inside element with n-close attribute (ancestor support)", async () => { + const popup = ( + +
+
+ Click anywhere inside this div + +
+
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + expect(popup.style.display).not.toBe("none") + + // Click on the inner button (child of element with n-close) + const innerButton = popup.querySelector("button") as HTMLButtonElement + expect(innerButton).toBeTruthy() + + innerButton.click() + + expect(popup.style.display).toBe("none") + }) + }) + + describe("Combined scenarios", () => { + it("should respect both segment and closed state conditions", async () => { + const popupName = "segment-popup" + + // First, close the popup + setPopupData({ name: popupName, state: "closed" }) + + // Set up segments that would normally show the popup + mockNostojs({ + internal: { + getSegments: () => Promise.resolve(["matching-segment"]) + } + }) + + const popup = ( + +
Content
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + // Should be hidden because it was closed, despite matching segment + expect(popup.style.display).toBe("none") + }) + + it("should hide popup when segment doesn't match, even without closed state", async () => { + mockNostojs({ + internal: { + getSegments: () => Promise.resolve(["other-segment"]) + } + }) + + const popup = ( + +
Content
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + expect(popup.style.display).toBe("none") + }) + }) + + describe("Ribbon mode functionality", () => { + it("should switch to ribbon mode when n-ribbon element is clicked", async () => { + const popup = ( + +
+ +
+
+ Ribbon content +
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + const ribbonButton = popup.querySelector("[n-ribbon]") as HTMLButtonElement + expect(ribbonButton).toBeTruthy() + + ribbonButton.click() + + // Check that ribbon state is stored + expect(getPopupData()).toEqual({ name: "ribbon-test-popup", state: "ribbon" }) + + // Check DOM structure after switch + const dialog = popup.shadowRoot?.querySelector('[part="dialog"]') + const ribbon = popup.shadowRoot?.querySelector('[part="ribbon"]') + expect(dialog?.hasAttribute("open")).toBe(false) + expect(ribbon?.classList.contains("hidden")).toBe(false) + }) + + it("should render in ribbon mode when localStorage state is 'ribbon'", async () => { + const popupName = "persistent-ribbon-popup" + setPopupData({ name: popupName, state: "ribbon" }) + + const popup = ( + +
Dialog content
+
Ribbon content
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + const dialog = popup.shadowRoot?.querySelector('[part="dialog"]') + const ribbon = popup.shadowRoot?.querySelector('[part="ribbon"]') + expect(dialog?.hasAttribute("open")).toBe(false) + expect(ribbon?.classList.contains("hidden")).toBe(false) + }) + + it("should prevent default and stop propagation on n-ribbon click", async () => { + const popup = ( + +
+ + Switch to Ribbon + +
+
+ ) as Popup + + document.body.appendChild(popup) + await popup.connectedCallback() + + const ribbonLink = popup.querySelector("[n-ribbon]") as HTMLAnchorElement + + const clickEvent = new Event("click", { bubbles: true, cancelable: true }) + const preventDefaultSpy = vi.spyOn(clickEvent, "preventDefault") + const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation") + + ribbonLink.dispatchEvent(clickEvent) + + expect(preventDefaultSpy).toHaveBeenCalled() + expect(stopPropagationSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/test/setup.ts b/test/setup.ts index 24d288dd..36e9e8ab 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -4,12 +4,20 @@ import "@/components/Campaign/Campaign" import "@/components/Control/Control" import "@/components/DynamicCard/DynamicCard" import "@/components/Image/Image" +import "@/components/Popup/Popup" import "@/components/Product/Product" import "@/components/ProductCard/ProductCard" import "@/components/SectionCampaign/SectionCampaign" import "@/components/SimpleCard/SimpleCard" import "@/components/SkuOptions/SkuOptions" +HTMLDialogElement.prototype.showModal = function () { + this.toggleAttribute("open", true) +} +HTMLDialogElement.prototype.close = function () { + this.toggleAttribute("open", false) +} + beforeAll(() => { // Components are automatically registered by their @customElement decorators // when the modules are imported above. diff --git a/test/utils/jsx.ts b/test/utils/jsx.ts index 1e6d9513..b01d50ff 100644 --- a/test/utils/jsx.ts +++ b/test/utils/jsx.ts @@ -2,6 +2,7 @@ import type { Campaign } from "@/components/Campaign/Campaign" import type { Control } from "@/components/Control/Control" import type { DynamicCard } from "@/components/DynamicCard/DynamicCard" import type { Image } from "@/components/Image/Image" +import type { Popup } from "@/components/Popup/Popup" import type { Product } from "@/components/Product/Product" import type { ProductCard } from "@/components/ProductCard/ProductCard" import type { SectionCampaign } from "@/components/SectionCampaign/SectionCampaign" @@ -26,6 +27,7 @@ declare global { "nosto-control": ElementMapping "nosto-dynamic-card": ElementMapping "nosto-image": ElementMapping + "nosto-popup": ElementMapping "nosto-product": ElementMapping "nosto-product-card": ElementMapping "nosto-section-campaign": ElementMapping