popup-button-plugin 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/popup-button.d.ts +25 -0
- package/dist/popup-button.js +160 -0
- package/dist/popup-registry.d.ts +20 -0
- package/dist/popup-registry.js +34 -0
- package/package.json +19 -4
package/README.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
-
#
|
|
1
|
+
# popup-button-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A tiny, **framework-free** web component: register content by name,
|
|
4
|
+
and `<popup-button name="...">` automatically shows it in a popup when
|
|
5
|
+
clicked. No React/Angular/Vue/Stencil required to use it — works in
|
|
6
|
+
any HTML page as-is, and inside React/Angular/Vue too since it's a
|
|
7
|
+
standard custom element.
|
|
8
|
+
|
|
9
|
+
See the full design write-up and integration steps in
|
|
10
|
+
[popup-button-prd.md](./popup-button-prd.md) and
|
|
11
|
+
[popup-button-integration-guide.md](./popup-button-integration-guide.md).
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install
|
|
17
|
+
npm run build
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { registerButtonContent } from 'popup-button-plugin';
|
|
22
|
+
|
|
23
|
+
registerButtonContent('save-info', {
|
|
24
|
+
title: 'Save changes?',
|
|
25
|
+
body: 'This will overwrite your previous draft.',
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
<popup-button name="save-info" label="Save"></popup-button>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Open `examples/index.html` (via a local static server, e.g. `npx serve .`)
|
|
34
|
+
to see it working.
|
|
35
|
+
|
|
36
|
+
## Why plain text only, no HTML content?
|
|
37
|
+
|
|
38
|
+
Registered `body` content is inserted as `textContent`/escaped, never
|
|
39
|
+
as raw HTML. This means it can never execute a script, even if the
|
|
40
|
+
content came from somewhere less trusted — safety by default.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// This is the single file an app imports. Importing it:
|
|
2
|
+
// 1. Registers the <popup-button> custom element (side effect of
|
|
3
|
+
// the import below — customElements.define runs once).
|
|
4
|
+
// 2. Gives the app the registry functions it needs to call.
|
|
5
|
+
// ".js" extensions required here too, same reason as in popup-button.ts.
|
|
6
|
+
export { registerButtonContent, getButtonContent, unregisterButtonContent } from './popup-registry.js';
|
|
7
|
+
export { PopupButton } from './popup-button.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare class PopupButton extends HTMLElement {
|
|
2
|
+
private isOpen;
|
|
3
|
+
private content?;
|
|
4
|
+
constructor();
|
|
5
|
+
/**
|
|
6
|
+
* Runs once, the first time this tag is attached to the page.
|
|
7
|
+
* (Equivalent to a component's "mount"/"componentDidMount".)
|
|
8
|
+
*/
|
|
9
|
+
connectedCallback(): void;
|
|
10
|
+
/** Reads the `name` HTML attribute, e.g. <popup-button name="save-info">. */
|
|
11
|
+
private get buttonName();
|
|
12
|
+
/** Reads the `label` HTML attribute (button text), with a default. */
|
|
13
|
+
private get label();
|
|
14
|
+
/** Runs when the visible <button> inside our shadow DOM is clicked. */
|
|
15
|
+
private handleButtonClick;
|
|
16
|
+
/** Runs when the popup's close button or backdrop is clicked. */
|
|
17
|
+
private handleClose;
|
|
18
|
+
/**
|
|
19
|
+
* Rebuilds this element's shadow DOM to match current state
|
|
20
|
+
* (isOpen / content). There's no automatic re-render like Stencil —
|
|
21
|
+
* we call this ourselves every time state changes.
|
|
22
|
+
*/
|
|
23
|
+
private render;
|
|
24
|
+
private renderPopupMarkup;
|
|
25
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// =====================================================================
|
|
2
|
+
// <popup-button> — a native Web Component (no framework, no build
|
|
3
|
+
// tool like Stencil needed to USE it — just plain JS/HTML).
|
|
4
|
+
// ---------------------------------------------------------------------
|
|
5
|
+
// WHY THIS FILE EXISTS / WHAT IT DOES:
|
|
6
|
+
// This is the ONE tag the app puts in HTML. On click, it:
|
|
7
|
+
// 1. Looks up content for its `name` attribute in the registry.
|
|
8
|
+
// 2. If found -> shows a popup (modal) with that content.
|
|
9
|
+
// 3. If NOT found -> fires a "popup-content-missing" event (so the
|
|
10
|
+
// app/dev can catch a typo instead of nothing happening silently).
|
|
11
|
+
//
|
|
12
|
+
// HOW CUSTOM ELEMENTS WORK (since there's no Stencil here to hide it):
|
|
13
|
+
// - A custom element is just a JS class that `extends HTMLElement`.
|
|
14
|
+
// - `customElements.define('popup-button', PopupButton)` registers the
|
|
15
|
+
// tag name <popup-button> to use this class, globally, once.
|
|
16
|
+
// - `connectedCallback()` runs automatically when the browser inserts
|
|
17
|
+
// the tag into the page (like a component "mount").
|
|
18
|
+
// - `attachShadow({ mode: 'open' })` gives the element its own isolated
|
|
19
|
+
// DOM + CSS, so our button/modal styles can't leak out, and page
|
|
20
|
+
// styles can't accidentally break our popup.
|
|
21
|
+
// =====================================================================
|
|
22
|
+
// NOTE the explicit ".js" extension: native browser <script type="module">
|
|
23
|
+
// imports require the real file extension (unlike bundlers such as
|
|
24
|
+
// Vite/webpack, which resolve extensionless imports for you). Without
|
|
25
|
+
// this, plain-HTML usage (our main selling point — no build tool
|
|
26
|
+
// needed) breaks with a 404 on this import.
|
|
27
|
+
import { getButtonContent } from './popup-registry.js';
|
|
28
|
+
export class PopupButton extends HTMLElement {
|
|
29
|
+
constructor() {
|
|
30
|
+
super();
|
|
31
|
+
// Tracks whether the popup is currently visible.
|
|
32
|
+
this.isOpen = false;
|
|
33
|
+
/** Runs when the visible <button> inside our shadow DOM is clicked. */
|
|
34
|
+
this.handleButtonClick = () => {
|
|
35
|
+
const found = getButtonContent(this.buttonName);
|
|
36
|
+
if (!found) {
|
|
37
|
+
// Nothing registered for this name — tell the outside world via
|
|
38
|
+
// a CustomEvent, instead of failing silently. `bubbles: true` so
|
|
39
|
+
// the app can listen on a parent element too, not just this tag.
|
|
40
|
+
this.dispatchEvent(new CustomEvent('popup-content-missing', {
|
|
41
|
+
detail: { name: this.buttonName },
|
|
42
|
+
bubbles: true,
|
|
43
|
+
}));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.content = found;
|
|
47
|
+
this.isOpen = true;
|
|
48
|
+
this.render();
|
|
49
|
+
};
|
|
50
|
+
/** Runs when the popup's close button or backdrop is clicked. */
|
|
51
|
+
this.handleClose = () => {
|
|
52
|
+
this.isOpen = false;
|
|
53
|
+
this.render();
|
|
54
|
+
};
|
|
55
|
+
// `mode: 'open'` means outside JS *can* still read this.shadowRoot
|
|
56
|
+
// if it really needs to (useful for testing); 'closed' would hide it.
|
|
57
|
+
this.attachShadow({ mode: 'open' });
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Runs once, the first time this tag is attached to the page.
|
|
61
|
+
* (Equivalent to a component's "mount"/"componentDidMount".)
|
|
62
|
+
*/
|
|
63
|
+
connectedCallback() {
|
|
64
|
+
this.render();
|
|
65
|
+
}
|
|
66
|
+
/** Reads the `name` HTML attribute, e.g. <popup-button name="save-info">. */
|
|
67
|
+
get buttonName() {
|
|
68
|
+
var _a;
|
|
69
|
+
return (_a = this.getAttribute('name')) !== null && _a !== void 0 ? _a : '';
|
|
70
|
+
}
|
|
71
|
+
/** Reads the `label` HTML attribute (button text), with a default. */
|
|
72
|
+
get label() {
|
|
73
|
+
var _a;
|
|
74
|
+
return (_a = this.getAttribute('label')) !== null && _a !== void 0 ? _a : 'Click me';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Rebuilds this element's shadow DOM to match current state
|
|
78
|
+
* (isOpen / content). There's no automatic re-render like Stencil —
|
|
79
|
+
* we call this ourselves every time state changes.
|
|
80
|
+
*/
|
|
81
|
+
render() {
|
|
82
|
+
const shadow = this.shadowRoot;
|
|
83
|
+
shadow.innerHTML = `
|
|
84
|
+
<style>${STYLES}</style>
|
|
85
|
+
<button class="btn">${escapeHtml(this.label)}</button>
|
|
86
|
+
${this.isOpen ? this.renderPopupMarkup() : ''}
|
|
87
|
+
`;
|
|
88
|
+
// Re-attach listeners every render, since innerHTML = '...' above
|
|
89
|
+
// destroys and recreates the actual DOM nodes (old listeners are
|
|
90
|
+
// gone with them).
|
|
91
|
+
shadow.querySelector('.btn').addEventListener('click', this.handleButtonClick);
|
|
92
|
+
if (this.isOpen) {
|
|
93
|
+
shadow.querySelector('.backdrop').addEventListener('click', this.handleClose);
|
|
94
|
+
shadow.querySelector('.close').addEventListener('click', this.handleClose);
|
|
95
|
+
// Stop clicks INSIDE the popup box from bubbling to the backdrop
|
|
96
|
+
// (otherwise clicking the popup content would also close it).
|
|
97
|
+
shadow.querySelector('.popup').addEventListener('click', (e) => e.stopPropagation());
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
renderPopupMarkup() {
|
|
101
|
+
var _a, _b, _c, _d;
|
|
102
|
+
const title = (_b = (_a = this.content) === null || _a === void 0 ? void 0 : _a.title) !== null && _b !== void 0 ? _b : '';
|
|
103
|
+
const body = (_d = (_c = this.content) === null || _c === void 0 ? void 0 : _c.body) !== null && _d !== void 0 ? _d : '';
|
|
104
|
+
return `
|
|
105
|
+
<div class="backdrop">
|
|
106
|
+
<div class="popup">
|
|
107
|
+
<div class="popup__header">
|
|
108
|
+
<span class="popup__title">${escapeHtml(title)}</span>
|
|
109
|
+
<button class="close" aria-label="Close">×</button>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="popup__body">${escapeHtml(body)}</div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Plain-text content only (see PRD): we escape it before inserting
|
|
118
|
+
// into innerHTML, so registered content is always treated as TEXT,
|
|
119
|
+
// never as HTML/script — this is what keeps this component safe from
|
|
120
|
+
// XSS even though we build markup with innerHTML.
|
|
121
|
+
function escapeHtml(value) {
|
|
122
|
+
const div = document.createElement('div');
|
|
123
|
+
div.textContent = value;
|
|
124
|
+
return div.innerHTML;
|
|
125
|
+
}
|
|
126
|
+
const STYLES = `
|
|
127
|
+
:host { display: inline-block; font-family: inherit; }
|
|
128
|
+
.btn {
|
|
129
|
+
padding: 8px 16px;
|
|
130
|
+
border-radius: 6px;
|
|
131
|
+
border: none;
|
|
132
|
+
background: #2563eb;
|
|
133
|
+
color: #fff;
|
|
134
|
+
font-size: 14px;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
}
|
|
137
|
+
.btn:hover { background: #1d4ed8; }
|
|
138
|
+
.backdrop {
|
|
139
|
+
position: fixed; inset: 0;
|
|
140
|
+
background: rgba(0,0,0,0.5);
|
|
141
|
+
display: flex; align-items: center; justify-content: center;
|
|
142
|
+
z-index: 1000;
|
|
143
|
+
}
|
|
144
|
+
.popup {
|
|
145
|
+
background: #fff; border-radius: 10px;
|
|
146
|
+
min-width: 320px; max-width: 90vw;
|
|
147
|
+
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
|
148
|
+
}
|
|
149
|
+
.popup__header {
|
|
150
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
151
|
+
padding: 16px; border-bottom: 1px solid #e5e7eb;
|
|
152
|
+
}
|
|
153
|
+
.popup__title { font-size: 16px; font-weight: 700; color: #111827; }
|
|
154
|
+
.close { background: none; border: none; font-size: 20px; cursor: pointer; color: #6b7280; }
|
|
155
|
+
.popup__body { padding: 16px; color: #374151; }
|
|
156
|
+
`;
|
|
157
|
+
// Registers the <popup-button> tag globally. This file only needs to
|
|
158
|
+
// be imported ONCE anywhere in the app for the tag to start working
|
|
159
|
+
// everywhere on the page.
|
|
160
|
+
customElements.define('popup-button', PopupButton);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The shape of content a popup can display. */
|
|
2
|
+
export interface PopupContent {
|
|
3
|
+
title?: string;
|
|
4
|
+
body: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Call this once per button `name` (usually right when your app starts)
|
|
8
|
+
* to say what should appear in the popup for that name.
|
|
9
|
+
*
|
|
10
|
+
* Example: registerButtonContent('save-info', { title: 'Save?', body: 'Sure?' })
|
|
11
|
+
*/
|
|
12
|
+
export declare function registerButtonContent(name: string, content: PopupContent): void;
|
|
13
|
+
/**
|
|
14
|
+
* Used internally by <popup-button> on click, to find what to show.
|
|
15
|
+
* Returns undefined if nothing was registered for this name — almost
|
|
16
|
+
* always means a typo somewhere.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getButtonContent(name: string): PopupContent | undefined;
|
|
19
|
+
/** Optional cleanup — e.g. remove a mapping when a page/feature unmounts. */
|
|
20
|
+
export declare function unregisterButtonContent(name: string): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// =====================================================================
|
|
2
|
+
// POPUP REGISTRY
|
|
3
|
+
// ---------------------------------------------------------------------
|
|
4
|
+
// WHY THIS FILE EXISTS:
|
|
5
|
+
// The app using this plugin should only have to say, ONCE:
|
|
6
|
+
// "when a button named X is clicked, show THIS content"
|
|
7
|
+
// We store that (name -> content) mapping here, in memory, so ANY
|
|
8
|
+
// <popup-button> on the page can look it up by its `name` attribute
|
|
9
|
+
// at click time. One shared "address book" for the whole page.
|
|
10
|
+
// =====================================================================
|
|
11
|
+
// Map (not a plain {} object) so a button literally named "toString"
|
|
12
|
+
// or "constructor" can never collide with built-in object properties.
|
|
13
|
+
const registry = new Map();
|
|
14
|
+
/**
|
|
15
|
+
* Call this once per button `name` (usually right when your app starts)
|
|
16
|
+
* to say what should appear in the popup for that name.
|
|
17
|
+
*
|
|
18
|
+
* Example: registerButtonContent('save-info', { title: 'Save?', body: 'Sure?' })
|
|
19
|
+
*/
|
|
20
|
+
export function registerButtonContent(name, content) {
|
|
21
|
+
registry.set(name, content);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Used internally by <popup-button> on click, to find what to show.
|
|
25
|
+
* Returns undefined if nothing was registered for this name — almost
|
|
26
|
+
* always means a typo somewhere.
|
|
27
|
+
*/
|
|
28
|
+
export function getButtonContent(name) {
|
|
29
|
+
return registry.get(name);
|
|
30
|
+
}
|
|
31
|
+
/** Optional cleanup — e.g. remove a mapping when a page/feature unmounts. */
|
|
32
|
+
export function unregisterButtonContent(name) {
|
|
33
|
+
registry.delete(name);
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "popup-button-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A tiny, standalone, framework-free button-with-popup web component: register content by name, and <popup-button name=\"...\"> automatically shows it when clicked.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"start": "tsc -p tsconfig.json --watch",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["web-components", "custom-elements", "popup", "modal", "button"],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"typescript": "^5.6.0"
|
|
20
|
+
}
|
|
21
|
+
}
|