-
Notifications
You must be signed in to change notification settings - Fork 0
feat(Popup): add new custom element with dual-mode dialog and ribbon functionality #436
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
timowestnosto
merged 15 commits into
main
from
copilot/fix-c98b8d45-0181-47c7-b7f2-a21104d2580c
Oct 7, 2025
Merged
Changes from 2 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
15bae6e
Initial plan
Copilot d077525
feat(Popup): add new custom element with dialog and ribbon slots
Copilot 1fbfa87
refactor(Popup): extract styles, move methods to top-level functions,β¦
Copilot 6a53239
refactor(Popup): move utility functions below class, combine checks iβ¦
Copilot 72ca515
refactor(Popup): add attribute docs, support n-close ancestors, removβ¦
Copilot c5d06d9
refactor(Popup): make name required, drop disconnectedCallback, improβ¦
Copilot 1324963
feat(Popup): add ribbon mode, shadow DOM parts, and global test setup
Copilot 54cafdb
refactor(Popup): consolidate state management and remove isPopupShownβ¦
Copilot 28eca92
refactor(Popup): replace renderShadowContent with updateShadowContentβ¦
Copilot e3135c7
refactor(Popup): extract getKey function, remove comments, extract inβ¦
Copilot 030cabe
fix(Popup): fix test unhandled errors and format issues
Copilot daa303d
refactor(Popup): improve click handling and initialization with mode β¦
Copilot 5d01322
chore: add manual fixes
timowestnosto b869c7a
chore: more manual fixes
timowestnosto d0f1b37
chore: add PopupState type
timowestnosto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| 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. | ||
|
|
||
| ## Features | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| - **Dialog slot**: Main popup content displayed in a centered modal dialog | ||
| - **Ribbon slot**: Additional content displayed in bottom-right corner | ||
| - **Segment filtering**: Only show popup to users in specified Nosto segments | ||
| - **Persistent closure**: Named popups remember when they've been closed | ||
| - **Click to close**: Elements with \`n-close\` attribute will close the popup | ||
| ` | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export default meta | ||
| type Story = StoryObj | ||
|
|
||
| export const Default: Story = { | ||
| args: {}, | ||
| render: () => html` | ||
| <nosto-popup> | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| <div slot="default" style="padding: 2rem; background: white; border-radius: 8px; max-width: 400px;"> | ||
| <h2 style="margin-top: 0;">Special Offer!</h2> | ||
| <p>Get 20% off your next purchase when you sign up for our newsletter.</p> | ||
| <button | ||
| style="background: #007acc; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;" | ||
| > | ||
| Sign Up Now | ||
| </button> | ||
| <button | ||
timowestnosto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| n-close | ||
| style="background: transparent; border: 1px solid #ccc; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; margin-left: 0.5rem;" | ||
| > | ||
| Close | ||
| </button> | ||
| </div> | ||
| </nosto-popup> | ||
| ` | ||
| } | ||
|
|
||
| export const WithRibbon: Story = { | ||
| args: {}, | ||
| render: () => html` | ||
| <nosto-popup name="ribbon-example"> | ||
| <div slot="default" style="padding: 2rem; background: white; border-radius: 8px; max-width: 400px;"> | ||
| <h2 style="margin-top: 0;">Limited Time Offer</h2> | ||
| <p>Don't miss out on this exclusive deal!</p> | ||
| <button | ||
| style="background: #e74c3c; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;" | ||
| > | ||
| Shop Now | ||
| </button> | ||
| <button | ||
| n-close | ||
| style="background: transparent; border: 1px solid #ccc; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; margin-left: 0.5rem;" | ||
| > | ||
| Maybe Later | ||
| </button> | ||
| </div> | ||
| <div | ||
| slot="ribbon" | ||
| style="background: #f39c12; color: white; padding: 0.75rem 1rem; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);" | ||
| > | ||
| <strong>β° Only 2 hours left!</strong> | ||
| <button | ||
| n-close | ||
| style="background: transparent; border: none; color: white; font-size: 1.2rem; cursor: pointer; margin-left: 0.5rem;" | ||
| > | ||
| Γ | ||
| </button> | ||
| </div> | ||
| </nosto-popup> | ||
| ` | ||
| } | ||
|
|
||
| export const Named: Story = { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| args: { | ||
| name: "demo-popup" | ||
| }, | ||
| render: args => html` | ||
| <nosto-popup name=${args.name}> | ||
| <div slot="default" style="padding: 2rem; background: white; border-radius: 8px; max-width: 400px;"> | ||
| <h2 style="margin-top: 0;">Named Popup</h2> | ||
| <p>This popup has a name and will remember if it's been closed.</p> | ||
| <p> | ||
| <small>Name: <code>${args.name}</code></small> | ||
| </p> | ||
| <button | ||
| n-close | ||
| style="background: #007acc; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;" | ||
| > | ||
| Close Forever | ||
| </button> | ||
| </div> | ||
| </nosto-popup> | ||
| ` | ||
| } | ||
|
|
||
| export const WithSegment: Story = { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| args: { | ||
| segment: "demo-segment" | ||
| }, | ||
| render: args => html` | ||
| <nosto-popup segment=${args.segment}> | ||
| <div slot="default" style="padding: 2rem; background: white; border-radius: 8px; max-width: 400px;"> | ||
| <h2 style="margin-top: 0;">Segment-Based Popup</h2> | ||
| <p>This popup only appears for users in the specified segment.</p> | ||
| <p> | ||
| <small>Required segment: <code>${args.segment}</code></small> | ||
| </p> | ||
| <button | ||
| n-close | ||
| style="background: #007acc; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;" | ||
| > | ||
| Got it! | ||
| </button> | ||
| </div> | ||
| </nosto-popup> | ||
| ` | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| import { nostojs } from "@nosto/nosto-js" | ||
| import { customElement } from "../decorators" | ||
| import { NostoElement } from "../Element" | ||
|
|
||
| /** | ||
| * A custom element that displays popup content with dialog and ribbon slots. | ||
| * Supports conditional activation based on Nosto segments and persistent closure state. | ||
| * | ||
| * @example | ||
| * Basic popup with dialog and ribbon content: | ||
| * ```html | ||
| * <nosto-popup name="promo-popup" segment="5b71f1500000000000000006"> | ||
| * <div slot="default"> | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * <h2>Special Offer!</h2> | ||
| * <p>Get 20% off your order today</p> | ||
| * <button n-close>Close</button> | ||
| * </div> | ||
| * <div slot="ribbon"> | ||
| * <span>Limited time!</span> | ||
| * <button n-close>Γ</button> | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * </div> | ||
| * </nosto-popup> | ||
| * ``` | ||
timowestnosto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| */ | ||
| @customElement("nosto-popup") | ||
| export class Popup extends NostoElement { | ||
| /** @private */ | ||
| static attributes = { | ||
timowestnosto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| name: String, | ||
| segment: String | ||
| } | ||
|
|
||
| name?: string | ||
| segment?: string | ||
|
|
||
| constructor() { | ||
| super() | ||
| this.attachShadow({ mode: "open" }) | ||
| } | ||
|
|
||
| async connectedCallback() { | ||
| // Check if popup was permanently closed | ||
| if (this.name && this.isPopupClosed()) { | ||
| this.style.display = "none" | ||
| return | ||
| } | ||
|
|
||
| // Check segment precondition if specified | ||
| if (this.segment && !(await this.checkSegment())) { | ||
| this.style.display = "none" | ||
| return | ||
| } | ||
|
|
||
| this.renderShadowContent() | ||
| this.setupEventListeners() | ||
| } | ||
|
|
||
| disconnectedCallback() { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| this.removeEventListeners() | ||
| } | ||
|
|
||
| private renderShadowContent() { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if (!this.shadowRoot) return | ||
| this.shadowRoot.innerHTML = ` | ||
| <style> | ||
| :host { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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; | ||
| z-index: 1001; | ||
| } | ||
|
|
||
| dialog::backdrop { | ||
| background: rgba(0, 0, 0, 0.5); | ||
| } | ||
|
|
||
| .ribbon { | ||
| position: fixed; | ||
| bottom: 20px; | ||
| right: 20px; | ||
| pointer-events: auto; | ||
| z-index: 1002; | ||
| } | ||
|
|
||
| .hidden { | ||
| display: none; | ||
| } | ||
| </style> | ||
| <dialog open> | ||
| <slot name="default"></slot> | ||
| </dialog> | ||
| <div class="ribbon"> | ||
| <slot name="ribbon"></slot> | ||
| </div> | ||
| ` | ||
| } | ||
|
|
||
| private setupEventListeners() { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| this.addEventListener("click", this.handleClick) | ||
| } | ||
|
|
||
| private removeEventListeners() { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| this.removeEventListener("click", this.handleClick) | ||
| } | ||
|
|
||
| private handleClick = (event: Event) => { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const target = event.target as HTMLElement | ||
| if (target?.hasAttribute("n-close")) { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| event.preventDefault() | ||
| event.stopPropagation() | ||
| this.closePopup() | ||
| } | ||
| } | ||
|
|
||
| private closePopup() { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if (this.name) { | ||
| this.setPopupClosed() | ||
| } | ||
| this.style.display = "none" | ||
| } | ||
|
|
||
| private isPopupClosed(): boolean { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if (!this.name) return false | ||
| const key = `nosto:web-components:popup:${this.name}` | ||
| return localStorage.getItem(key) === "true" | ||
| } | ||
|
|
||
| private setPopupClosed() { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if (!this.name) return | ||
| const key = `nosto:web-components:popup:${this.name}` | ||
| localStorage.setItem(key, "true") | ||
| } | ||
|
|
||
| private async checkSegment(): Promise<boolean> { | ||
timowestnosto marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if (!this.segment) return true | ||
|
|
||
| try { | ||
| const api = await new Promise(nostojs) | ||
| const segments = await api.internal.getSegments() | ||
| return segments?.includes(this.segment) || false | ||
| } catch { | ||
| return false | ||
| } | ||
| } | ||
| } | ||
|
|
||
| declare global { | ||
| interface HTMLElementTagNameMap { | ||
| "nosto-popup": Popup | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.