├── .editorconfig ├── composer.json ├── src ├── editor.scss ├── save.js ├── index.js ├── block.json ├── view.js ├── style.scss └── edit.js ├── blueprint.json ├── .gitignore ├── tests └── block-registration.spec.js ├── .github └── workflows │ ├── lint.yml │ ├── pr-cleanup.yml │ ├── build-and-release.yml │ ├── release.yml │ ├── playwright-tests.yml │ └── pr-playground-preview.yml ├── playwright.config.js ├── package.json ├── readme.txt └── popup.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = tab 15 | 16 | [*.{yml,yaml}] 17 | indent_style = space 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "humanmade/popup", 3 | "description": "An exit intent popup block that shows when someone may be about to leave the site", 4 | "type": "wordpress-plugin", 5 | "license": "GPL-2.0-or-later", 6 | "authors": [ 7 | { 8 | "name": "Human Made Limited", 9 | "email": "engineering@humanmade.com" 10 | } 11 | ], 12 | "require": { 13 | "composer/installers": "^1 || ^2" 14 | }, 15 | "config": { 16 | "allow-plugins": { 17 | "composer/installers": true 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/editor.scss: -------------------------------------------------------------------------------- 1 | // stylelint-disable function-comma-space-after 2 | .wp-block-hm-popup { 3 | position: relative; 4 | top: 0 !important; 5 | width: auto; 6 | height: auto; 7 | max-height: max-content; 8 | opacity: 1; 9 | margin: 0; 10 | padding: 40px !important; 11 | background-color: rgba(0, 0, 0, 0.3) !important; 12 | background-image: repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.3) 10px, rgba(0, 0, 0, 0.3) 10px, rgba(0, 0, 0, 0.3) 20px) !important; 13 | z-index: 1 !important; 14 | } 15 | -------------------------------------------------------------------------------- /blueprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "landingPage": "/wp-admin/", 3 | "login": true, 4 | "preferredVersions": { 5 | "php": "8.3", 6 | "wp": "6.8" 7 | }, 8 | "steps": [ 9 | { 10 | "step": "installTheme", 11 | "themeData": { 12 | "resource": "wordpress.org/themes", 13 | "slug": "twentytwentyfive" 14 | }, 15 | "options": { 16 | "activate": true 17 | } 18 | }, 19 | { 20 | "step": "defineWpConfigConsts", 21 | "consts": { 22 | "WP_DEBUG": true, 23 | "WP_DEBUG_LOG": true, 24 | "SCRIPT_DEBUG": true 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Coverage directory used by tools like istanbul 9 | coverage 10 | 11 | # Build output 12 | build/ 13 | 14 | # Dependency directories 15 | node_modules/ 16 | 17 | # Optional npm cache directory 18 | .npm 19 | 20 | # Optional eslint cache 21 | .eslintcache 22 | 23 | # Output of `npm pack` 24 | *.tgz 25 | 26 | # Output of `wp-scripts plugin-zip` 27 | *.zip 28 | 29 | # dotenv environment variables file 30 | .env 31 | 32 | /vendor/ 33 | composer.lock 34 | 35 | # Test output 36 | test-results/ 37 | playwright-report/ 38 | -------------------------------------------------------------------------------- /tests/block-registration.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); 5 | 6 | test.describe( 'Popup Block', () => { 7 | test( 'should be registered', async ( { admin, editor } ) => { 8 | await admin.createNewPost(); 9 | 10 | // Open the block inserter and search for the popup block 11 | await editor.insertBlock( { name: 'hm/popup' } ); 12 | 13 | // Verify the block is inserted 14 | const blocks = await editor.getBlocks(); 15 | expect( blocks ).toHaveLength( 1 ); 16 | expect( blocks[ 0 ].name ).toBe( 'hm/popup' ); 17 | } ); 18 | } ); 19 | -------------------------------------------------------------------------------- /src/save.js: -------------------------------------------------------------------------------- 1 | import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; 2 | 3 | /** 4 | * @param {Object} props 5 | * @param {Object} props.attributes 6 | * @return {Element} Element to render. 7 | */ 8 | export default function save( { attributes } ) { 9 | const blockProps = useBlockProps.save(); 10 | const { children, ...innerBlocksProps } = 11 | useInnerBlocksProps.save( blockProps ); 12 | 13 | return ( 14 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: '24' 22 | cache: 'npm' 23 | 24 | - name: Install dependencies 25 | run: npm i 26 | 27 | - name: Lint JavaScript 28 | run: npm run lint:js 29 | 30 | - name: Lint CSS 31 | run: npm run lint:css 32 | -------------------------------------------------------------------------------- /.github/workflows/pr-cleanup.yml: -------------------------------------------------------------------------------- 1 | name: PR Cleanup 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | cleanup: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Delete PR preview branch 19 | run: | 20 | BRANCH_NAME="pr-${{ github.event.pull_request.number }}-built" 21 | if git ls-remote --exit-code --heads origin "$BRANCH_NAME" > /dev/null 2>&1; then 22 | git push origin --delete "$BRANCH_NAME" 23 | echo "Deleted branch: $BRANCH_NAME" 24 | else 25 | echo "Branch $BRANCH_NAME does not exist, no cleanup needed" 26 | fi 27 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@playwright/test').PlaywrightTestConfig} 3 | */ 4 | const config = { 5 | testDir: './tests', 6 | fullyParallel: true, 7 | forbidOnly: !! process.env.CI, 8 | retries: process.env.CI ? 2 : 0, 9 | workers: process.env.CI ? 1 : undefined, 10 | reporter: [ 11 | [ 'html', { open: process.env.CI ? 'never' : 'on-failure' } ], 12 | [ 'json', { outputFile: 'test-results/results.json' } ], 13 | [ 'list' ], 14 | ], 15 | use: { 16 | baseURL: process.env.WP_BASE_URL || 'http://localhost:9400', 17 | trace: 'on-first-retry', 18 | }, 19 | projects: [ 20 | { 21 | name: 'chromium', 22 | use: { browserName: 'chromium' }, 23 | }, 24 | ], 25 | webServer: process.env.CI 26 | ? undefined 27 | : { 28 | command: 'npm run playground:start', 29 | port: 9400, 30 | reuseExistingServer: true, 31 | timeout: 120000, 32 | }, 33 | }; 34 | 35 | module.exports = config; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "popup", 3 | "private": true, 4 | "description": "Popup block for WordPress", 5 | "author": "Human Made Limited", 6 | "license": "GPL-2.0-or-later", 7 | "main": "build/index.js", 8 | "scripts": { 9 | "build": "wp-scripts build", 10 | "format": "wp-scripts format", 11 | "lint:css": "wp-scripts lint-style", 12 | "lint:js": "wp-scripts lint-js", 13 | "packages-update": "wp-scripts packages-update", 14 | "plugin-zip": "wp-scripts plugin-zip", 15 | "start": "wp-scripts start", 16 | "playground:start": "npx --yes @wp-playground/cli@latest server --blueprint=blueprint.json --port=9400 --auto-mount", 17 | "test:e2e": "playwright test", 18 | "test:e2e:debug": "playwright test --debug", 19 | "test:e2e:watch": "playwright test --ui" 20 | }, 21 | "devDependencies": { 22 | "@playwright/test": "^1.49.0", 23 | "@wordpress/e2e-test-utils-playwright": "^1.15.0", 24 | "@wordpress/scripts": "^31.1.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Exit Popup === 2 | Contributors: humanmade 3 | Tags: block, popup, exit popup, marketing 4 | Tested up to: 6.8.2 5 | Stable tag: 0.2.2 6 | License: GPL-2.0-or-later 7 | License URI: https://www.gnu.org/licenses/gpl-2.0.html 8 | 9 | A lightweight popup wrapper block that can display any content you wish. 10 | 11 | == Description == 12 | 13 | Built using modern web standards, this block lets you drop any content you like inside. Use it to create your UI patterns. 14 | 15 | Features: 16 | 17 | - Trigger on click from any link or button 18 | - Trigger on exit intent 19 | 20 | == Installation == 21 | 22 | 1. Upload the plugin files to the `/wp-content/plugins/exit-popup` directory, or install the plugin through the WordPress plugins screen directly. 23 | 1. Activate the plugin through the 'Plugins' screen in WordPress 24 | 25 | 26 | == Frequently Asked Questions == 27 | 28 | == Screenshots == 29 | 30 | == Changelog == 31 | 32 | = 0.1.0 = 33 | * Release 34 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Production Build & Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref_name }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | release: 14 | name: 'Update release branch' 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out project 18 | uses: actions/checkout@v5 19 | 20 | - name: Set up Node 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 24 24 | cache: 'npm' 25 | 26 | - name: Merge and build 27 | uses: humanmade/hm-github-actions/.github/actions/build-to-release-branch@04c32a93e52ae987095f144105745a501d6207c8 28 | with: 29 | source_branch: main 30 | release_branch: release 31 | built_asset_paths: build 32 | build_script: | 33 | npm i 34 | npm run build 35 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Registers a new block provided a unique name and an object defining its behavior. 3 | * 4 | * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/ 5 | */ 6 | import { registerBlockType } from '@wordpress/blocks'; 7 | 8 | /** 9 | * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files. 10 | * All files containing `style` keyword are bundled together. The code used 11 | * gets applied both to the front of your site and to the editor. 12 | * 13 | * @see https://www.npmjs.com/package/@wordpress/scripts#using-css 14 | */ 15 | import './style.scss'; 16 | 17 | /** 18 | * Internal dependencies 19 | */ 20 | import Edit from './edit'; 21 | import save from './save'; 22 | import metadata from './block.json'; 23 | 24 | /** 25 | * Every block starts by registering a new block type definition. 26 | * 27 | * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/ 28 | */ 29 | registerBlockType( metadata.name, { 30 | /** 31 | * @see ./edit.js 32 | */ 33 | edit: Edit, 34 | 35 | /** 36 | * @see ./save.js 37 | */ 38 | save, 39 | } ); 40 | -------------------------------------------------------------------------------- /src/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 3, 4 | "name": "hm/popup", 5 | "title": "Popup Block", 6 | "category": "widgets", 7 | "icon": "exit", 8 | "description": "Popup wrapper block", 9 | "example": {}, 10 | "allowedBlocks": [ "core/group" ], 11 | "supports": { 12 | "html": false, 13 | "anchor": true, 14 | "multiple": true, 15 | "layout": { 16 | "default": { 17 | "type": "constrained" 18 | }, 19 | "allowJustification": false, 20 | "allowEditing": true, 21 | "allowCustomContentAndWideSize": true, 22 | "allowInheriting": false 23 | }, 24 | "color": { 25 | "text": false, 26 | "background": true, 27 | "__experimentalSkipSerialization": true 28 | }, 29 | "background": { 30 | "backgroundImage": true, 31 | "backgroundSize": true, 32 | "__experimentalSkipSerialization": true 33 | } 34 | }, 35 | "attributes": { 36 | "opacity": { 37 | "type": "number", 38 | "default": 75 39 | }, 40 | "trigger": { 41 | "type": "string", 42 | "default": "click", 43 | "enum": [ "click", "exit" ] 44 | }, 45 | "cookieExpiration": { 46 | "type": "number", 47 | "default": 7 48 | } 49 | }, 50 | "selectors": { 51 | "root": ".wp-block-hm-popup", 52 | "color": ".wp-block-hm-popup::backdrop", 53 | "background": ".wp-block-hm-popup::backdrop" 54 | }, 55 | "textdomain": "hm-popup", 56 | "editorScript": "file:./index.js", 57 | "editorStyle": "file:./index.css", 58 | "style": "file:./style-index.css", 59 | "viewScript": "file:./view.js" 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Version and Release 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | ref: ${{ github.event.release.tag_name }} 19 | fetch-depth: 0 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '24' 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm i 29 | 30 | - name: Get version from tag 31 | id: version 32 | run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT 33 | 34 | - name: Update version in plugin file 35 | run: sed -i "s/__VERSION__/${{ steps.version.outputs.version }}/g" popup.php 36 | 37 | - name: Build 38 | run: npm run build 39 | 40 | - name: Commit version update 41 | run: | 42 | git config user.name "github-actions[bot]" 43 | git config user.email "github-actions[bot]@users.noreply.github.com" 44 | git add . 45 | git commit -m "Update version to ${{ steps.version.outputs.version }}" 46 | 47 | - name: Update tag 48 | run: | 49 | git tag -f ${{ github.event.release.tag_name }} 50 | git push origin ${{ github.event.release.tag_name }} --force 51 | 52 | - name: Create plugin ZIP 53 | run: npm run plugin-zip 54 | 55 | - name: Upload release asset 56 | run: | 57 | gh release upload ${{ github.event.release.tag_name }} popup.zip --clobber 58 | env: 59 | GH_TOKEN: ${{ github.token }} 60 | -------------------------------------------------------------------------------- /src/view.js: -------------------------------------------------------------------------------- 1 | const mouseEvent = ( e ) => { 2 | const shouldShowExitIntent = 3 | ! e.toElement && ! e.relatedTarget && e.clientY < 10; 4 | 5 | if ( shouldShowExitIntent ) { 6 | document.removeEventListener( 'mouseout', mouseEvent ); 7 | document.querySelector( 'html' ).classList.add( 'has-modal-open' ); 8 | document 9 | .querySelector( '.wp-block-hm-popup[data-trigger="exit"]' ) 10 | .showModal(); 11 | window.localStorage.setItem( 'exitIntentShown', Date.now() ); 12 | } 13 | }; 14 | 15 | const bootstrap = () => { 16 | let exitIntentSetup = false; 17 | document.querySelectorAll( '.wp-block-hm-popup' ).forEach( ( popup ) => { 18 | // Block selectors API doesn't work so we need to 19 | 20 | // On close remove HTML class. 21 | popup.addEventListener( 'close', () => { 22 | document 23 | .querySelector( 'html' ) 24 | .classList.remove( 'has-modal-open' ); 25 | } ); 26 | 27 | // On backdrop click, close modal. 28 | popup.addEventListener( 'mousedown', ( event ) => { 29 | if ( event.target === event.currentTarget ) { 30 | event.currentTarget.close(); 31 | } 32 | } ); 33 | 34 | // Handle click trigger. 35 | if ( popup?.dataset.trigger === 'click' ) { 36 | document 37 | .querySelectorAll( `[href$="#${ popup.id || 'open-popup' }"]` ) 38 | .forEach( ( trigger ) => { 39 | trigger.addEventListener( 'click', ( event ) => { 40 | event.preventDefault(); 41 | document 42 | .querySelector( 'html' ) 43 | .classList.add( 'has-modal-open' ); 44 | popup.showModal(); 45 | } ); 46 | } ); 47 | } 48 | 49 | // Handle exit intent trigger. 50 | if ( popup?.dataset.trigger === 'exit' ) { 51 | // Get expiry setting on local storage value. 52 | const expirationDays = parseInt( popup?.dataset.expiry || 7, 10 ); 53 | 54 | if ( 55 | parseInt( 56 | window.localStorage.getItem( 'exitIntentShown' ) || 0, 57 | 10 58 | ) < 59 | Date.now() - expirationDays * 24 * 60 * 60 * 1000 && 60 | ! exitIntentSetup 61 | ) { 62 | exitIntentSetup = true; 63 | setTimeout( () => { 64 | document.addEventListener( 'mouseout', mouseEvent ); 65 | }, 2000 ); 66 | } 67 | } 68 | } ); 69 | 70 | // Bind close events. 71 | document 72 | .querySelectorAll( 73 | [ 74 | '.wp-block-hm-popup__close', 75 | '.wp-block-hm-popup [href$="#close"]', 76 | ].join( ',' ) 77 | ) 78 | .forEach( ( el ) => { 79 | el.addEventListener( 'click', ( event ) => { 80 | event.preventDefault(); 81 | event.currentTarget.closest( '.wp-block-hm-popup' ).close(); 82 | } ); 83 | } ); 84 | }; 85 | 86 | // Handle async scripts. 87 | if ( document.readyState !== 'loading' ) { 88 | bootstrap(); 89 | } else { 90 | document.addEventListener( 'DOMContentLoaded', bootstrap ); 91 | } 92 | -------------------------------------------------------------------------------- /src/style.scss: -------------------------------------------------------------------------------- 1 | .wp-block-hm-popup { 2 | top: 100%; 3 | opacity: 0; 4 | transition: 5 | opacity 150ms linear, 6 | top 0s linear 150ms, 7 | left 0s linear 150ms, 8 | right 0s linear 150ms, 9 | overlay 150ms linear allow-discrete, 10 | display 150ms linear allow-discrete; 11 | align-items: center; 12 | overflow: auto; 13 | z-index: 200; 14 | box-sizing: border-box; 15 | max-width: 100vw; 16 | max-height: 100vh; 17 | width: 100vw; 18 | height: 100vh; 19 | border: 0; 20 | background: none; 21 | padding: 5vh 5vw; 22 | margin: 0 auto; 23 | margin-block-start: 0 !important; 24 | margin-block-end: 0 !important; 25 | 26 | &.is-style-side--left, 27 | &.is-style-side--right { 28 | padding: 0; 29 | } 30 | 31 | &[open] { 32 | top: 0; 33 | opacity: 1; 34 | } 35 | 36 | &[open].is-style-side--left { 37 | right: 0; 38 | } 39 | 40 | &[open].is-style-side--right { 41 | left: 0; 42 | } 43 | 44 | > * { 45 | max-width: var(--wp--style--global--content-size, 680px); 46 | position: relative; 47 | z-index: 2; 48 | width: 100%; 49 | background-color: rgb(255, 255, 255); 50 | margin-block-start: 0 !important; 51 | margin-top: 0 !important; 52 | margin-bottom: 0 !important; 53 | } 54 | 55 | &.is-style-side--left > *, 56 | &.is-style-side--right > * { 57 | height: 100%; 58 | overflow: auto; 59 | } 60 | 61 | &.is-style-side--left > * { 62 | margin-left: 0 !important; 63 | } 64 | 65 | &.is-style-side--right > * { 66 | margin-right: 0 !important; 67 | } 68 | } 69 | 70 | /* Before-open state */ 71 | 72 | /* Needs to be after the previous dialog[open] rule to take effect, 73 | as the specificity is the same */ 74 | @starting-style { 75 | 76 | .wp-block-hm-popup[open] { 77 | opacity: 0; 78 | top: 100%; 79 | } 80 | 81 | .wp-block-hm-popup[open].is-style-side--left { 82 | top: 0; 83 | bottom: 0; 84 | right: 100%; 85 | } 86 | 87 | .wp-block-hm-popup[open].is-style-side--right { 88 | top: 0; 89 | bottom: 0; 90 | left: 100%; 91 | } 92 | } 93 | 94 | /* Transition the :backdrop when the dialog modal is promoted to 95 | the top layer */ 96 | .wp-block-hm-popup::backdrop { 97 | background-color: rgba(0, 0, 0, 0%); 98 | transition: 99 | display 150ms allow-discrete, 100 | overlay 150ms allow-discrete, 101 | background-color 150ms; 102 | } 103 | 104 | .wp-block-hm-popup[open]::backdrop { 105 | background-color: rgba(0, 0, 0, 75%); 106 | } 107 | 108 | /* This starting-style rule cannot be nested inside the above selector 109 | because the nesting selector cannot represent pseudo-elements. */ 110 | @starting-style { 111 | 112 | .wp-block-hm-popup[open]::backdrop { 113 | background-color: rgba(0, 0, 0, 0%); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /.github/workflows/playwright-tests.yml: -------------------------------------------------------------------------------- 1 | name: Playwright E2E Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | php: ['8.3', '8.4'] 18 | wp: ['6.8', '6.9', 'latest'] 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Node 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: '24' 28 | cache: 'npm' 29 | 30 | - name: Install dependencies 31 | run: npm i 32 | 33 | - name: Build plugin 34 | run: npm run build 35 | 36 | - name: Install Playwright 37 | run: npx playwright install chromium 38 | 39 | - name: Start WordPress Playground 40 | run: | 41 | npx --yes @wp-playground/cli@latest server start \ 42 | --blueprint=blueprint.json \ 43 | --port=9400 \ 44 | --php=${{ matrix.php }} \ 45 | --wp=${{ matrix.wp }} \ 46 | --auto-mount & 47 | echo $! > playground.pid 48 | 49 | - name: Wait for Playground 50 | run: | 51 | timeout 120 bash -c 'until curl -s http://localhost:9400 > /dev/null; do sleep 2; done' 52 | 53 | - name: Run E2E tests 54 | run: npm run test:e2e 55 | env: 56 | WP_BASE_URL: http://localhost:9400 57 | CI: true 58 | 59 | - name: Upload test results 60 | if: always() 61 | uses: actions/upload-artifact@v4 62 | with: 63 | name: test-results-php${{ matrix.php }}-wp${{ matrix.wp }} 64 | path: test-results/ 65 | retention-days: 30 66 | 67 | - name: Upload test artifacts 68 | if: failure() 69 | uses: actions/upload-artifact@v4 70 | with: 71 | name: test-artifacts-php${{ matrix.php }}-wp${{ matrix.wp }} 72 | path: playwright-report/ 73 | retention-days: 7 74 | 75 | - name: Stop Playground 76 | if: always() 77 | run: | 78 | if [ -f playground.pid ]; then 79 | kill $(cat playground.pid) || true 80 | fi 81 | 82 | - name: Comment test results on PR 83 | if: github.event_name == 'pull_request' && always() 84 | uses: daun/playwright-report-summary@v3 85 | with: 86 | report-file: test-results/results.json 87 | comment-title: 'Playwright E2E Test Results (PHP ${{ matrix.php }}, WP ${{ matrix.wp }})' 88 | -------------------------------------------------------------------------------- /.github/workflows/pr-playground-preview.yml: -------------------------------------------------------------------------------- 1 | name: PR Playground Preview 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | - reopened 9 | - edited 10 | 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | 15 | jobs: 16 | preview: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: '24' 26 | cache: 'npm' 27 | 28 | - name: Install dependencies 29 | run: npm i 30 | 31 | - name: Update version placeholder 32 | run: | 33 | sed -i "s/__VERSION__/Pull Request #${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }}/g" popup.php 34 | 35 | - name: Build 36 | run: npm run build 37 | 38 | - name: Push built files to PR branch 39 | run: | 40 | git config user.name "github-actions[bot]" 41 | git config user.email "github-actions[bot]@users.noreply.github.com" 42 | git checkout -B pr-${{ github.event.pull_request.number }}-built 43 | git add -f build/ 44 | git add popup.php 45 | git commit -m "Build for PR #${{ github.event.pull_request.number }} - ${{ github.sha }}" 46 | git push origin pr-${{ github.event.pull_request.number }}-built --force 47 | 48 | - name: Create preview blueprint 49 | id: blueprint 50 | run: | 51 | BLUEPRINT=$(cat <<'EOF' 52 | { 53 | "landingPage": "/wp-admin/site-editor.php", 54 | "login": true, 55 | "steps": [ 56 | { 57 | "step": "installTheme", 58 | "themeData": { 59 | "resource": "wordpress.org/themes", 60 | "slug": "twentytwentyfive" 61 | }, 62 | "options": { 63 | "activate": true 64 | } 65 | }, 66 | { 67 | "step": "installPlugin", 68 | "pluginZipFile": { 69 | "resource": "url", 70 | "url": "https://github-proxy.com/proxy/?repo=${{ github.repository }}&branch=pr-${{ github.event.pull_request.number }}-built" 71 | } 72 | } 73 | ] 74 | } 75 | EOF 76 | ) 77 | echo "blueprint=$(echo $BLUEPRINT | jq -c .)" >> $GITHUB_OUTPUT 78 | 79 | - name: Add Playground preview 80 | uses: WordPress/action-wp-playground-pr-preview@v2 81 | with: 82 | blueprint: ${{ steps.blueprint.outputs.blueprint }} 83 | github-token: ${{ secrets.GITHUB_TOKEN }} 84 | -------------------------------------------------------------------------------- /popup.php: -------------------------------------------------------------------------------- 1 | [ 59 | 'valueless' => 'y', 60 | ], 61 | ] ); 62 | 63 | return $html; 64 | } 65 | 66 | add_filter( 'wp_kses_allowed_html', __NAMESPACE__ . '\\filter_wp_kses_allowed_html', 10, 2 ); 67 | 68 | /** 69 | * Filters the content of the block. 70 | * 71 | * @param string $block_content The block content. 72 | * @param array $block The full block, including name and attributes. 73 | * @param \WP_Block $instance The block instance. 74 | * @return string The block content. 75 | */ 76 | function filter_render_block( $block_content, $block, \WP_Block $instance ) { 77 | 78 | $classname = 'wp-elements-' . md5( maybe_serialize( $block['attrs'] ) ); 79 | 80 | $style = WP_Style_Engine::compile_stylesheet_from_css_rules( 81 | new WP_Style_Engine_CSS_Rule( ".{$classname}::backdrop", [ 82 | 'opacity' => ( $block['attrs']['opacity'] ?? '75' ) . '%', 83 | 'background-color' => "var(--wp--preset--color--{$block['attrs']['backgroundColor']}) !important", 84 | 'background-image' => "url({$block['attrs']['style']['background']['url']})", 85 | 'background-size' => $block['attrs']['style']['background']['backgroundSize'] ?? 'cover', 86 | ] ) 87 | ); 88 | 89 | wp_enqueue_block_support_styles( $style ); 90 | 91 | $block_content = new WP_HTML_Tag_Processor( $block_content ); 92 | $block_content->next_tag(); 93 | $block_content->add_class( $classname ); 94 | 95 | return (string) $block_content; 96 | } 97 | 98 | add_filter( 'render_block_hm/popup', __NAMESPACE__ . '\\filter_render_block', 10, 3 ); 99 | 100 | add_action( 'init', __NAMESPACE__ . '\\action_init' ); 101 | 102 | /** 103 | * Fires after WordPress has finished loading but before any headers are sent. 104 | * 105 | */ 106 | function action_init() : void { 107 | register_block_style( 108 | 'hm/popup', 109 | [ 110 | 'name' => 'side--left', 111 | 'label' => __( 'Left Side', 'hm-popup' ), 112 | ] 113 | ); 114 | 115 | register_block_style( 116 | 'hm/popup', 117 | [ 118 | 'name' => 'side--right', 119 | 'label' => __( 'Right Side', 'hm-popup' ), 120 | ] 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/edit.js: -------------------------------------------------------------------------------- 1 | import { useState } from '@wordpress/element'; 2 | import { __ } from '@wordpress/i18n'; 3 | 4 | import { 5 | useBlockProps, 6 | useInnerBlocksProps, 7 | InspectorControls, 8 | } from '@wordpress/block-editor'; 9 | 10 | import { 11 | ClipboardButton, 12 | PanelBody, 13 | RangeControl, 14 | SelectControl, 15 | TextControl, 16 | } from '@wordpress/components'; 17 | 18 | import './editor.scss'; 19 | 20 | const TRIGGERS = [ 21 | { value: 'click', label: __( 'On click', 'hm-popup' ) }, 22 | { value: 'exit', label: __( 'On exit intent', 'hm-popup' ) }, 23 | ]; 24 | 25 | /** 26 | * @param {Object} props 27 | * @param {Object} props.attributes 28 | * @param {Function} props.setAttributes 29 | * @return {Element} Element to render. 30 | */ 31 | export default function Edit( { attributes, setAttributes } ) { 32 | // Manually handle background classes and styles because we're skipping automatic serialisation. 33 | const { backgroundColor, style } = attributes; 34 | const classNames = []; 35 | const styles = {}; 36 | if ( backgroundColor ) { 37 | classNames.push( 38 | `has-background-color has-${ backgroundColor }-background-color` 39 | ); 40 | } 41 | if ( style?.background ) { 42 | style.backgroundImage = `url(${ style.background?.url })`; 43 | style.backgroundSize = style.background?.backgroundSize || 'cover'; 44 | } 45 | 46 | const { ...blockProps } = useBlockProps( { 47 | className: classNames.join( ' ' ), 48 | style: styles, 49 | } ); 50 | const { children, ...innerBlocksProps } = useInnerBlocksProps( blockProps, { 51 | template: [ 52 | [ 53 | 'core/group', 54 | { 55 | lock: { 56 | move: true, 57 | remove: true, 58 | }, 59 | backgroundColor: 'white', 60 | style: { 61 | spacing: { 62 | padding: { 63 | top: 'var:preset|spacing|40', 64 | bottom: 'var:preset|spacing|40', 65 | left: 'var:preset|spacing|40', 66 | right: 'var:preset|spacing|40', 67 | }, 68 | }, 69 | }, 70 | }, 71 | [ [ 'core/paragraph' ] ], 72 | ], 73 | ], 74 | allowedBlocks: [ 'core/group' ], 75 | renderAppender: false, 76 | } ); 77 | 78 | const [ hasCopied, setHasCopied ] = useState( false ); 79 | 80 | return ( 81 |
86 | { __( 87 | 'The popup will be invisible until the user moves their mouse cursor up and out of the window or tab.', 88 | 'hm-popup' 89 | ) } 90 |
91 | ) } 92 |93 | { __( 94 | 'Clicking the background or pressing escape will close the popup.', 95 | 'hm-popup' 96 | ) } 97 |
98 |99 | { __( 100 | 'A link or button with the URL "#close" anywhere within the popup will also close it.', 101 | 'hm-popup' 102 | ) } 103 |
104 |#{ attributes.anchor }
123 |