@marianmeres/widget-provider 1.0.2
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/AGENTS.md +45 -0
- package/API.md +204 -0
- package/CLAUDE.md +3 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/mod.d.ts +4 -0
- package/dist/mod.js +3 -0
- package/dist/style-presets.d.ts +18 -0
- package/dist/style-presets.js +86 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.js +2 -0
- package/dist/widget-provider.d.ts +20 -0
- package/dist/widget-provider.js +296 -0
- package/docs/architecture.md +50 -0
- package/docs/conventions.md +65 -0
- package/docs/tasks.md +71 -0
- package/package.json +25 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @marianmeres/widget-provider — Agent Guide
|
|
2
|
+
|
|
3
|
+
## Quick Reference
|
|
4
|
+
- **Stack**: Deno, TypeScript, browser DOM APIs
|
|
5
|
+
- **Runtime**: Deno (primary), npm (secondary via build)
|
|
6
|
+
- **Dependency**: `@marianmeres/store` (reactive state)
|
|
7
|
+
- **Test**: `deno test` | **Build**: `deno task npm:build` | **Publish**: `deno task publish`
|
|
8
|
+
|
|
9
|
+
## Project Structure
|
|
10
|
+
```
|
|
11
|
+
/src
|
|
12
|
+
mod.ts — Public entry point (re-exports)
|
|
13
|
+
types.ts — All type definitions and constants
|
|
14
|
+
style-presets.ts — CSS preset configs and apply functions
|
|
15
|
+
widget-provider.ts — Core provideWidget() implementation
|
|
16
|
+
/tests — Deno tests (unit tests for pure functions)
|
|
17
|
+
/scripts — npm build script
|
|
18
|
+
/example — Dev example app
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What This Library Does
|
|
22
|
+
Embeds an iframe-based widget into a host page with:
|
|
23
|
+
- Style presets (float, fullscreen, inline) for positioning
|
|
24
|
+
- postMessage-based bidirectional communication (namespaced with `@@__widget_provider__@@`)
|
|
25
|
+
- Show/hide animations (fade-scale, slide-up)
|
|
26
|
+
- Optional trigger button (auto-toggles with widget visibility)
|
|
27
|
+
- Reactive state via `@marianmeres/store` (Svelte-compatible subscribe)
|
|
28
|
+
|
|
29
|
+
## Critical Conventions
|
|
30
|
+
1. All message types are prefixed with `MSG_PREFIX` (`@@__widget_provider__@@`)
|
|
31
|
+
2. Types live in `types.ts`, style logic in `style-presets.ts`, core logic in `widget-provider.ts`
|
|
32
|
+
3. `mod.ts` is the sole public entry point — all public exports go through it
|
|
33
|
+
4. Use Deno formatting: tabs, 90 char line width (`deno fmt`)
|
|
34
|
+
5. `provideWidget()` is the only user-facing factory — returns `WidgetProviderApi`
|
|
35
|
+
|
|
36
|
+
## Before Making Changes
|
|
37
|
+
- [ ] Check existing patterns in similar files
|
|
38
|
+
- [ ] Run `deno test`
|
|
39
|
+
- [ ] Run `deno fmt`
|
|
40
|
+
- [ ] Ensure all public exports are re-exported from `mod.ts`
|
|
41
|
+
|
|
42
|
+
## Documentation Index
|
|
43
|
+
- [Architecture](./docs/architecture.md)
|
|
44
|
+
- [Conventions](./docs/conventions.md)
|
|
45
|
+
- [Tasks](./docs/tasks.md)
|
package/API.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# API
|
|
2
|
+
|
|
3
|
+
## Functions
|
|
4
|
+
|
|
5
|
+
### `provideWidget(options)`
|
|
6
|
+
|
|
7
|
+
Create and embed an iframe-based widget. Returns a control API object.
|
|
8
|
+
|
|
9
|
+
**Parameters:**
|
|
10
|
+
- `options` (`WidgetProviderOptions`) — Configuration object (see below)
|
|
11
|
+
|
|
12
|
+
**Returns:** `WidgetProviderApi`
|
|
13
|
+
|
|
14
|
+
**Example:**
|
|
15
|
+
```typescript
|
|
16
|
+
import { provideWidget } from '@marianmeres/widget-provider';
|
|
17
|
+
|
|
18
|
+
const widget = provideWidget({
|
|
19
|
+
widgetUrl: 'https://example.com/widget',
|
|
20
|
+
stylePreset: 'float',
|
|
21
|
+
animate: 'slide-up',
|
|
22
|
+
trigger: { content: '<span>Chat</span>' },
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
### `resolveAllowedOrigins(explicit, widgetUrl)`
|
|
29
|
+
|
|
30
|
+
Resolve the list of allowed origins for postMessage validation.
|
|
31
|
+
|
|
32
|
+
**Parameters:**
|
|
33
|
+
- `explicit` (`string | string[] | undefined`) — Explicitly configured origin(s)
|
|
34
|
+
- `widgetUrl` (`string`) — The widget URL to derive origin from
|
|
35
|
+
|
|
36
|
+
**Returns:** `string[]` — Array of allowed origin strings
|
|
37
|
+
|
|
38
|
+
**Example:**
|
|
39
|
+
```typescript
|
|
40
|
+
resolveAllowedOrigins(undefined, 'https://example.com/app');
|
|
41
|
+
// => ['https://example.com']
|
|
42
|
+
|
|
43
|
+
resolveAllowedOrigins(['https://a.com', 'https://b.com'], 'https://c.com/app');
|
|
44
|
+
// => ['https://a.com', 'https://b.com']
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
### `isOriginAllowed(origin, allowed)`
|
|
50
|
+
|
|
51
|
+
Check whether a given origin is in the allowed list.
|
|
52
|
+
|
|
53
|
+
**Parameters:**
|
|
54
|
+
- `origin` (`string`) — Origin to check
|
|
55
|
+
- `allowed` (`string[]`) — List of allowed origins (use `"*"` to allow any)
|
|
56
|
+
|
|
57
|
+
**Returns:** `boolean`
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### `resolveAnimateConfig(opt)`
|
|
62
|
+
|
|
63
|
+
Resolve animation option into a concrete `AnimateConfig` or `null`.
|
|
64
|
+
|
|
65
|
+
**Parameters:**
|
|
66
|
+
- `opt` (`boolean | AnimatePreset | { preset?: AnimatePreset; transition?: string } | undefined`)
|
|
67
|
+
|
|
68
|
+
**Returns:** `AnimateConfig | null`
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Types
|
|
73
|
+
|
|
74
|
+
### `WidgetProviderOptions`
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
interface WidgetProviderOptions {
|
|
78
|
+
/** The URL of the SPA to embed (required) */
|
|
79
|
+
widgetUrl: string;
|
|
80
|
+
/** DOM element to append the widget into. Default: document.body */
|
|
81
|
+
parentContainer?: HTMLElement;
|
|
82
|
+
/** Positioning mode. Default: "inline" */
|
|
83
|
+
stylePreset?: StylePreset;
|
|
84
|
+
/** CSS overrides applied to the container wrapper div */
|
|
85
|
+
styleOverrides?: StyleOverrides;
|
|
86
|
+
/** Allowed origin(s) for postMessage validation. Derived from widgetUrl if omitted */
|
|
87
|
+
allowedOrigin?: string | string[];
|
|
88
|
+
/** Whether the widget starts visible. Default: true */
|
|
89
|
+
visible?: boolean;
|
|
90
|
+
/** Iframe sandbox attribute. Default: "allow-scripts allow-same-origin" */
|
|
91
|
+
sandbox?: string;
|
|
92
|
+
/** Additional iframe attributes (e.g. allow, referrerpolicy) */
|
|
93
|
+
iframeAttrs?: Record<string, string>;
|
|
94
|
+
/** Opt-in show/hide animation: true | AnimatePreset | { preset?, transition? } */
|
|
95
|
+
animate?: boolean | AnimatePreset | { preset?: AnimatePreset; transition?: string };
|
|
96
|
+
/** Built-in floating trigger button: true | { content?, style? } */
|
|
97
|
+
trigger?: boolean | { content?: string; style?: Partial<CSSStyleDeclaration> };
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
### `WidgetProviderApi`
|
|
104
|
+
|
|
105
|
+
The object returned by `provideWidget()`.
|
|
106
|
+
|
|
107
|
+
| Method / Property | Signature | Description |
|
|
108
|
+
|-------------------|-----------|-------------|
|
|
109
|
+
| `show()` | `() => void` | Show the widget container |
|
|
110
|
+
| `hide()` | `() => void` | Hide the widget container |
|
|
111
|
+
| `toggle()` | `() => void` | Toggle visibility |
|
|
112
|
+
| `destroy()` | `() => void` | Remove iframe, listeners, DOM elements. Irreversible |
|
|
113
|
+
| `setPreset(preset)` | `(preset: StylePreset) => void` | Switch style preset at runtime |
|
|
114
|
+
| `maximize()` | `() => void` | Switch to fullscreen preset |
|
|
115
|
+
| `minimize()` | `() => void` | Switch back to initial preset |
|
|
116
|
+
| `requestNativeFullscreen()` | `() => Promise<void>` | Browser fullscreen for iframe |
|
|
117
|
+
| `exitNativeFullscreen()` | `() => Promise<void>` | Exit browser fullscreen |
|
|
118
|
+
| `send(type, payload?)` | `<T>(type: string, payload?: T) => void` | Send message to iframe |
|
|
119
|
+
| `onMessage(type, handler)` | `<T>(type: string, handler: (payload: T) => void) => Unsubscribe` | Listen for iframe messages |
|
|
120
|
+
| `subscribe(cb)` | `(cb: (state: WidgetState) => void) => Unsubscribe` | Reactive state subscription |
|
|
121
|
+
| `get()` | `() => WidgetState` | Get current state snapshot |
|
|
122
|
+
| `iframe` | `readonly HTMLIFrameElement` | Direct iframe element reference |
|
|
123
|
+
| `container` | `readonly HTMLElement` | Direct container div reference |
|
|
124
|
+
| `trigger` | `readonly HTMLElement \| null` | Trigger button reference, or null |
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
### `WidgetState`
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
interface WidgetState {
|
|
132
|
+
visible: boolean;
|
|
133
|
+
ready: boolean;
|
|
134
|
+
destroyed: boolean;
|
|
135
|
+
preset: StylePreset;
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### `WidgetMessage<T>`
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
interface WidgetMessage<T = unknown> {
|
|
145
|
+
type: string;
|
|
146
|
+
payload?: T;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
### `StylePreset`
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
type StylePreset = "float" | "fullscreen" | "inline";
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### `AnimatePreset`
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
type AnimatePreset = "fade-scale" | "slide-up";
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
### `StyleOverrides`
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
type StyleOverrides = Partial<CSSStyleDeclaration>;
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
### `AnimateConfig`
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
interface AnimateConfig {
|
|
180
|
+
transition: string;
|
|
181
|
+
hidden: Partial<CSSStyleDeclaration>;
|
|
182
|
+
visible: Partial<CSSStyleDeclaration>;
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Constants
|
|
189
|
+
|
|
190
|
+
### `MSG_PREFIX`
|
|
191
|
+
|
|
192
|
+
`"@@__widget_provider__@@"` — Namespace prefix for all postMessage types.
|
|
193
|
+
|
|
194
|
+
### `STYLE_PRESETS`
|
|
195
|
+
|
|
196
|
+
`Record<StylePreset, Partial<CSSStyleDeclaration>>` — CSS property objects for each positioning mode.
|
|
197
|
+
|
|
198
|
+
### `ANIMATE_PRESETS`
|
|
199
|
+
|
|
200
|
+
`Record<AnimatePreset, AnimateConfig>` — Animation configurations for show/hide transitions.
|
|
201
|
+
|
|
202
|
+
### `IFRAME_BASE`
|
|
203
|
+
|
|
204
|
+
`Partial<CSSStyleDeclaration>` — Base CSS applied to all iframes (100% width/height, no border).
|
package/CLAUDE.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marian Meres
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @marianmeres/widget-provider
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@marianmeres/widget-provider)
|
|
4
|
+
[](https://jsr.io/@marianmeres/widget-provider)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Embed an iframe-based widget into a host page with built-in positioning presets,
|
|
8
|
+
bidirectional postMessage communication, show/hide animations, and reactive state.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @marianmeres/widget-provider
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or via JSR:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
deno add jsr:@marianmeres/widget-provider
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { provideWidget } from '@marianmeres/widget-provider';
|
|
26
|
+
|
|
27
|
+
const widget = provideWidget({
|
|
28
|
+
widgetUrl: 'https://example.com/my-widget',
|
|
29
|
+
stylePreset: 'float', // "float" | "fullscreen" | "inline"
|
|
30
|
+
animate: true, // fade-scale animation
|
|
31
|
+
trigger: true, // show floating trigger button when hidden
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Control visibility
|
|
35
|
+
widget.show();
|
|
36
|
+
widget.hide();
|
|
37
|
+
widget.toggle();
|
|
38
|
+
|
|
39
|
+
// Send messages to the iframe
|
|
40
|
+
widget.send('greet', { name: 'World' });
|
|
41
|
+
|
|
42
|
+
// Listen for messages from the iframe
|
|
43
|
+
const unsub = widget.onMessage('response', (payload) => {
|
|
44
|
+
console.log(payload);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Subscribe to reactive state changes
|
|
48
|
+
widget.subscribe((state) => {
|
|
49
|
+
console.log(state.visible, state.ready);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Clean up
|
|
53
|
+
widget.destroy();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Style Presets
|
|
57
|
+
|
|
58
|
+
| Preset | Description |
|
|
59
|
+
|--------|-------------|
|
|
60
|
+
| `"inline"` | Flows within parent container (default) |
|
|
61
|
+
| `"float"` | Fixed bottom-right chat-widget style |
|
|
62
|
+
| `"fullscreen"` | Covers viewport with backdrop overlay |
|
|
63
|
+
|
|
64
|
+
### Message Protocol
|
|
65
|
+
|
|
66
|
+
Messages between the host and iframe are namespaced with `@@__widget_provider__@@`
|
|
67
|
+
prefix. The iframe can send built-in control messages: `ready`, `maximize`, `minimize`,
|
|
68
|
+
`hide`, `close`, `setPreset`, `nativeFullscreen`, `exitNativeFullscreen`.
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
See [API.md](API.md) for complete API documentation.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
[MIT](LICENSE)
|
package/dist/mod.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { provideWidget, resolveAllowedOrigins, isOriginAllowed, resolveAnimateConfig, } from "./widget-provider.js";
|
|
2
|
+
export type { WidgetProviderOptions, WidgetProviderApi, WidgetState, WidgetMessage, StylePreset, StyleOverrides, AnimatePreset, MessageHandler, Unsubscribe, } from "./types.js";
|
|
3
|
+
export { MSG_PREFIX } from "./types.js";
|
|
4
|
+
export { STYLE_PRESETS, IFRAME_BASE, ANIMATE_PRESETS, type AnimateConfig, } from "./style-presets.js";
|
package/dist/mod.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AnimatePreset, StylePreset } from "./types.js";
|
|
2
|
+
type CSSProps = Partial<CSSStyleDeclaration>;
|
|
3
|
+
/** CSS transition and visibility states for a show/hide animation */
|
|
4
|
+
export interface AnimateConfig {
|
|
5
|
+
transition: string;
|
|
6
|
+
hidden: CSSProps;
|
|
7
|
+
visible: CSSProps;
|
|
8
|
+
}
|
|
9
|
+
/** Built-in animation configurations keyed by {@linkcode AnimatePreset} name */
|
|
10
|
+
export declare const ANIMATE_PRESETS: Record<AnimatePreset, AnimateConfig>;
|
|
11
|
+
/** Base CSS styles applied to every widget iframe (100% size, no border) */
|
|
12
|
+
export declare const IFRAME_BASE: CSSProps;
|
|
13
|
+
export declare const TRIGGER_BASE: CSSProps;
|
|
14
|
+
/** CSS property objects for each positioning mode keyed by {@linkcode StylePreset} name */
|
|
15
|
+
export declare const STYLE_PRESETS: Record<StylePreset, CSSProps>;
|
|
16
|
+
export declare function applyPreset(container: HTMLElement, preset: StylePreset, overrides: Partial<CSSStyleDeclaration>): void;
|
|
17
|
+
export declare function applyIframeBaseStyles(iframe: HTMLIFrameElement): void;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Built-in animation configurations keyed by {@linkcode AnimatePreset} name */
|
|
2
|
+
export const ANIMATE_PRESETS = {
|
|
3
|
+
"fade-scale": {
|
|
4
|
+
transition: "opacity 200ms ease, transform 200ms ease",
|
|
5
|
+
hidden: { opacity: "0", transform: "scale(0.9)" },
|
|
6
|
+
visible: { opacity: "1", transform: "scale(1)" },
|
|
7
|
+
},
|
|
8
|
+
"slide-up": {
|
|
9
|
+
transition: "opacity 200ms ease, transform 200ms ease",
|
|
10
|
+
hidden: { opacity: "0", transform: "translateY(20px)" },
|
|
11
|
+
visible: { opacity: "1", transform: "translateY(0)" },
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
const BASE_CONTAINER = {
|
|
15
|
+
boxSizing: "border-box",
|
|
16
|
+
overflow: "hidden",
|
|
17
|
+
};
|
|
18
|
+
/** Base CSS styles applied to every widget iframe (100% size, no border) */
|
|
19
|
+
export const IFRAME_BASE = {
|
|
20
|
+
width: "100%",
|
|
21
|
+
height: "100%",
|
|
22
|
+
border: "none",
|
|
23
|
+
display: "block",
|
|
24
|
+
};
|
|
25
|
+
const PRESET_FLOAT = {
|
|
26
|
+
...BASE_CONTAINER,
|
|
27
|
+
position: "fixed",
|
|
28
|
+
bottom: "20px",
|
|
29
|
+
right: "20px",
|
|
30
|
+
width: "380px",
|
|
31
|
+
height: "520px",
|
|
32
|
+
zIndex: "10000",
|
|
33
|
+
borderRadius: "12px",
|
|
34
|
+
boxShadow: "0 4px 24px rgba(0,0,0,0.15)",
|
|
35
|
+
};
|
|
36
|
+
const PRESET_FULLSCREEN = {
|
|
37
|
+
...BASE_CONTAINER,
|
|
38
|
+
position: "fixed",
|
|
39
|
+
top: "0",
|
|
40
|
+
left: "0",
|
|
41
|
+
width: "100vw",
|
|
42
|
+
height: "100vh",
|
|
43
|
+
zIndex: "10000",
|
|
44
|
+
padding: "2rem",
|
|
45
|
+
backgroundColor: "rgba(0,0,0,0.5)",
|
|
46
|
+
};
|
|
47
|
+
const PRESET_INLINE = {
|
|
48
|
+
...BASE_CONTAINER,
|
|
49
|
+
position: "relative",
|
|
50
|
+
width: "100%",
|
|
51
|
+
height: "100%",
|
|
52
|
+
};
|
|
53
|
+
export const TRIGGER_BASE = {
|
|
54
|
+
position: "fixed",
|
|
55
|
+
bottom: "20px",
|
|
56
|
+
right: "20px",
|
|
57
|
+
width: "56px",
|
|
58
|
+
height: "56px",
|
|
59
|
+
borderRadius: "50%",
|
|
60
|
+
border: "none",
|
|
61
|
+
background: "#1a73e8",
|
|
62
|
+
color: "white",
|
|
63
|
+
cursor: "pointer",
|
|
64
|
+
zIndex: "10001",
|
|
65
|
+
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
|
66
|
+
display: "flex",
|
|
67
|
+
alignItems: "center",
|
|
68
|
+
justifyContent: "center",
|
|
69
|
+
padding: "0",
|
|
70
|
+
};
|
|
71
|
+
/** CSS property objects for each positioning mode keyed by {@linkcode StylePreset} name */
|
|
72
|
+
export const STYLE_PRESETS = {
|
|
73
|
+
float: PRESET_FLOAT,
|
|
74
|
+
fullscreen: PRESET_FULLSCREEN,
|
|
75
|
+
inline: PRESET_INLINE,
|
|
76
|
+
};
|
|
77
|
+
export function applyPreset(container, preset, overrides) {
|
|
78
|
+
const base = STYLE_PRESETS[preset];
|
|
79
|
+
if (!base) {
|
|
80
|
+
throw new Error(`Unknown style preset: "${preset}"`);
|
|
81
|
+
}
|
|
82
|
+
Object.assign(container.style, base, overrides);
|
|
83
|
+
}
|
|
84
|
+
export function applyIframeBaseStyles(iframe) {
|
|
85
|
+
Object.assign(iframe.style, IFRAME_BASE);
|
|
86
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Namespace prefix for all widget-provider postMessage types */
|
|
2
|
+
export declare const MSG_PREFIX = "@@__widget_provider__@@";
|
|
3
|
+
/** The structured envelope for all host <-> iframe messages */
|
|
4
|
+
export interface WidgetMessage<T = unknown> {
|
|
5
|
+
type: string;
|
|
6
|
+
payload?: T;
|
|
7
|
+
}
|
|
8
|
+
/** Built-in positioning modes for the widget container */
|
|
9
|
+
export type StylePreset = "float" | "fullscreen" | "inline";
|
|
10
|
+
/** Named animation presets for show/hide transitions */
|
|
11
|
+
export type AnimatePreset = "fade-scale" | "slide-up";
|
|
12
|
+
/** CSS overrides applied on top of a style preset */
|
|
13
|
+
export type StyleOverrides = Partial<CSSStyleDeclaration>;
|
|
14
|
+
/** Callback for handling a typed message payload from the widget iframe */
|
|
15
|
+
export type MessageHandler<T = unknown> = (payload: T) => void;
|
|
16
|
+
/** Function that removes a previously registered listener or subscription */
|
|
17
|
+
export type Unsubscribe = () => void;
|
|
18
|
+
/** Configuration options for {@linkcode provideWidget} */
|
|
19
|
+
export interface WidgetProviderOptions {
|
|
20
|
+
/** The URL of the SPA to embed */
|
|
21
|
+
widgetUrl: string;
|
|
22
|
+
/** DOM element to append the widget into. Defaults to document.body */
|
|
23
|
+
parentContainer?: HTMLElement;
|
|
24
|
+
/**
|
|
25
|
+
* Positioning mode. Defaults to "inline".
|
|
26
|
+
* - "float": fixed bottom-right chat-widget style
|
|
27
|
+
* - "fullscreen": covers viewport with optional backdrop
|
|
28
|
+
* - "inline": flows within parent container
|
|
29
|
+
*/
|
|
30
|
+
stylePreset?: StylePreset;
|
|
31
|
+
/** CSS overrides applied to the container wrapper div */
|
|
32
|
+
styleOverrides?: StyleOverrides;
|
|
33
|
+
/**
|
|
34
|
+
* Allowed origin(s) for postMessage validation.
|
|
35
|
+
* If omitted, derived from widgetUrl.
|
|
36
|
+
* Use "*" to allow any origin (not recommended for production).
|
|
37
|
+
*/
|
|
38
|
+
allowedOrigin?: string | string[];
|
|
39
|
+
/** Whether the widget starts visible. Defaults to true */
|
|
40
|
+
visible?: boolean;
|
|
41
|
+
/** Iframe sandbox attribute. Defaults to "allow-scripts allow-same-origin" */
|
|
42
|
+
sandbox?: string;
|
|
43
|
+
/** Additional iframe attributes (e.g. allow, referrerpolicy) */
|
|
44
|
+
iframeAttrs?: Record<string, string>;
|
|
45
|
+
/**
|
|
46
|
+
* Opt-in show/hide animation.
|
|
47
|
+
* - `true` → default "fade-scale" preset
|
|
48
|
+
* - string → named preset ("fade-scale" | "slide-up")
|
|
49
|
+
* - object → named preset + CSS transition override
|
|
50
|
+
*/
|
|
51
|
+
animate?: boolean | AnimatePreset | {
|
|
52
|
+
preset?: AnimatePreset;
|
|
53
|
+
/** CSS transition shorthand override */
|
|
54
|
+
transition?: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Built-in floating trigger button.
|
|
58
|
+
* If `true`, uses default styles/icon. If object, allows customization.
|
|
59
|
+
* Automatically shown when widget is hidden, hidden when widget is visible.
|
|
60
|
+
*/
|
|
61
|
+
trigger?: boolean | {
|
|
62
|
+
/** HTML content for the button (e.g. SVG icon). Defaults to a chat bubble SVG. */
|
|
63
|
+
content?: string;
|
|
64
|
+
/** CSS overrides for the trigger button */
|
|
65
|
+
style?: Partial<CSSStyleDeclaration>;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Reactive state tracked in the store */
|
|
69
|
+
export interface WidgetState {
|
|
70
|
+
visible: boolean;
|
|
71
|
+
ready: boolean;
|
|
72
|
+
destroyed: boolean;
|
|
73
|
+
preset: StylePreset;
|
|
74
|
+
}
|
|
75
|
+
/** Control API returned by {@linkcode provideWidget} */
|
|
76
|
+
export interface WidgetProviderApi {
|
|
77
|
+
/** Show the widget container */
|
|
78
|
+
show(): void;
|
|
79
|
+
/** Hide the widget container */
|
|
80
|
+
hide(): void;
|
|
81
|
+
/** Toggle visibility */
|
|
82
|
+
toggle(): void;
|
|
83
|
+
/** Remove iframe, event listeners, container from DOM. Irreversible. */
|
|
84
|
+
destroy(): void;
|
|
85
|
+
/** Switch to a specific style preset at runtime */
|
|
86
|
+
setPreset(preset: StylePreset): void;
|
|
87
|
+
/** Shortcut: switch to fullscreen preset */
|
|
88
|
+
maximize(): void;
|
|
89
|
+
/** Shortcut: switch back to the initial preset */
|
|
90
|
+
minimize(): void;
|
|
91
|
+
/** Request native browser fullscreen for the iframe */
|
|
92
|
+
requestNativeFullscreen(): Promise<void>;
|
|
93
|
+
/** Exit native browser fullscreen */
|
|
94
|
+
exitNativeFullscreen(): Promise<void>;
|
|
95
|
+
/** Send a typed message to the iframe */
|
|
96
|
+
send<T = unknown>(type: string, payload?: T): void;
|
|
97
|
+
/** Listen for a typed message from the iframe. Returns unsubscribe. */
|
|
98
|
+
onMessage<T = unknown>(type: string, handler: MessageHandler<T>): Unsubscribe;
|
|
99
|
+
/** Svelte-compatible store subscribe for reactive state */
|
|
100
|
+
subscribe(cb: (state: WidgetState) => void): Unsubscribe;
|
|
101
|
+
/** Direct getter for current state */
|
|
102
|
+
get(): WidgetState;
|
|
103
|
+
/** Direct reference to the iframe element */
|
|
104
|
+
readonly iframe: HTMLIFrameElement;
|
|
105
|
+
/** Direct reference to the container wrapper div */
|
|
106
|
+
readonly container: HTMLElement;
|
|
107
|
+
/** Direct reference to the trigger button element, or null if not configured */
|
|
108
|
+
readonly trigger: HTMLElement | null;
|
|
109
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type AnimateConfig } from "./style-presets.js";
|
|
2
|
+
import { type WidgetProviderApi, type WidgetProviderOptions } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the list of allowed origins for postMessage validation.
|
|
5
|
+
* Uses explicit value if provided, otherwise derives from widgetUrl. Falls back to `["*"]`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveAllowedOrigins(explicit: string | string[] | undefined, widgetUrl: string): string[];
|
|
8
|
+
/** Check whether a given origin is permitted by the allowed origins list. */
|
|
9
|
+
export declare function isOriginAllowed(origin: string, allowed: string[]): boolean;
|
|
10
|
+
/** Resolve the `animate` option into a concrete {@linkcode AnimateConfig} or `null` if disabled. */
|
|
11
|
+
export declare function resolveAnimateConfig(opt: WidgetProviderOptions["animate"]): AnimateConfig | null;
|
|
12
|
+
/**
|
|
13
|
+
* Create and embed an iframe-based widget into the host page.
|
|
14
|
+
*
|
|
15
|
+
* Creates a sandboxed iframe, applies the chosen style preset, wires up
|
|
16
|
+
* bidirectional postMessage communication, and returns a control API.
|
|
17
|
+
*
|
|
18
|
+
* @throws {Error} If `widgetUrl` is not provided.
|
|
19
|
+
*/
|
|
20
|
+
export declare function provideWidget(options: WidgetProviderOptions): WidgetProviderApi;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { createStore } from "@marianmeres/store";
|
|
2
|
+
import { ANIMATE_PRESETS, applyIframeBaseStyles, applyPreset, STYLE_PRESETS, TRIGGER_BASE, } from "./style-presets.js";
|
|
3
|
+
import { MSG_PREFIX, } from "./types.js";
|
|
4
|
+
const DEFAULT_TRIGGER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the list of allowed origins for postMessage validation.
|
|
7
|
+
* Uses explicit value if provided, otherwise derives from widgetUrl. Falls back to `["*"]`.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveAllowedOrigins(explicit, widgetUrl) {
|
|
10
|
+
if (explicit) {
|
|
11
|
+
return Array.isArray(explicit) ? explicit : [explicit];
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return [new URL(widgetUrl).origin];
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return ["*"];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Check whether a given origin is permitted by the allowed origins list. */
|
|
21
|
+
export function isOriginAllowed(origin, allowed) {
|
|
22
|
+
if (allowed.includes("*"))
|
|
23
|
+
return true;
|
|
24
|
+
return allowed.includes(origin);
|
|
25
|
+
}
|
|
26
|
+
/** Resolve the `animate` option into a concrete {@linkcode AnimateConfig} or `null` if disabled. */
|
|
27
|
+
export function resolveAnimateConfig(opt) {
|
|
28
|
+
if (!opt)
|
|
29
|
+
return null;
|
|
30
|
+
if (opt === true)
|
|
31
|
+
return ANIMATE_PRESETS["fade-scale"];
|
|
32
|
+
if (typeof opt === "string")
|
|
33
|
+
return ANIMATE_PRESETS[opt] ?? null;
|
|
34
|
+
const base = ANIMATE_PRESETS[opt.preset ?? "fade-scale"];
|
|
35
|
+
if (!base)
|
|
36
|
+
return null;
|
|
37
|
+
return opt.transition ? { ...base, transition: opt.transition } : base;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create and embed an iframe-based widget into the host page.
|
|
41
|
+
*
|
|
42
|
+
* Creates a sandboxed iframe, applies the chosen style preset, wires up
|
|
43
|
+
* bidirectional postMessage communication, and returns a control API.
|
|
44
|
+
*
|
|
45
|
+
* @throws {Error} If `widgetUrl` is not provided.
|
|
46
|
+
*/
|
|
47
|
+
export function provideWidget(options) {
|
|
48
|
+
const { widgetUrl, parentContainer, stylePreset = "inline", styleOverrides = {}, allowedOrigin, visible = true, sandbox = "allow-scripts allow-same-origin", iframeAttrs = {}, } = options;
|
|
49
|
+
if (!widgetUrl) {
|
|
50
|
+
throw new Error("widgetUrl is required");
|
|
51
|
+
}
|
|
52
|
+
const origins = resolveAllowedOrigins(allowedOrigin, widgetUrl);
|
|
53
|
+
const initialPreset = stylePreset;
|
|
54
|
+
const anim = resolveAnimateConfig(options.animate);
|
|
55
|
+
// reactive state
|
|
56
|
+
const state = createStore({
|
|
57
|
+
visible,
|
|
58
|
+
ready: false,
|
|
59
|
+
destroyed: false,
|
|
60
|
+
preset: stylePreset,
|
|
61
|
+
});
|
|
62
|
+
// DOM
|
|
63
|
+
const container = document.createElement("div");
|
|
64
|
+
const iframe = document.createElement("iframe");
|
|
65
|
+
applyPreset(container, stylePreset, styleOverrides);
|
|
66
|
+
applyIframeBaseStyles(iframe);
|
|
67
|
+
iframe.src = widgetUrl;
|
|
68
|
+
if (sandbox) {
|
|
69
|
+
iframe.setAttribute("sandbox", sandbox);
|
|
70
|
+
}
|
|
71
|
+
iframe.setAttribute("allowfullscreen", "");
|
|
72
|
+
for (const [k, v] of Object.entries(iframeAttrs)) {
|
|
73
|
+
iframe.setAttribute(k, v);
|
|
74
|
+
}
|
|
75
|
+
container.appendChild(iframe);
|
|
76
|
+
if (anim) {
|
|
77
|
+
container.style.transition = anim.transition;
|
|
78
|
+
}
|
|
79
|
+
if (!visible) {
|
|
80
|
+
container.style.display = "none";
|
|
81
|
+
if (anim) {
|
|
82
|
+
Object.assign(container.style, anim.hidden);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// messaging
|
|
86
|
+
const messageHandlers = new Map();
|
|
87
|
+
function handleMessage(event) {
|
|
88
|
+
if (!isOriginAllowed(event.origin, origins))
|
|
89
|
+
return;
|
|
90
|
+
if (event.source !== iframe.contentWindow)
|
|
91
|
+
return;
|
|
92
|
+
const data = event.data;
|
|
93
|
+
if (!data || typeof data.type !== "string")
|
|
94
|
+
return;
|
|
95
|
+
if (!data.type.startsWith(MSG_PREFIX))
|
|
96
|
+
return;
|
|
97
|
+
// built-in control messages
|
|
98
|
+
const bareType = data.type.slice(MSG_PREFIX.length);
|
|
99
|
+
switch (bareType) {
|
|
100
|
+
case "ready":
|
|
101
|
+
state.update((s) => ({ ...s, ready: true }));
|
|
102
|
+
break;
|
|
103
|
+
case "maximize":
|
|
104
|
+
maximize();
|
|
105
|
+
break;
|
|
106
|
+
case "minimize":
|
|
107
|
+
minimize();
|
|
108
|
+
break;
|
|
109
|
+
case "hide":
|
|
110
|
+
hide();
|
|
111
|
+
break;
|
|
112
|
+
case "close":
|
|
113
|
+
destroy();
|
|
114
|
+
break;
|
|
115
|
+
case "setPreset":
|
|
116
|
+
if (typeof data.payload === "string" &&
|
|
117
|
+
data.payload in STYLE_PRESETS) {
|
|
118
|
+
setPreset(data.payload);
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
case "nativeFullscreen":
|
|
122
|
+
requestNativeFullscreen();
|
|
123
|
+
break;
|
|
124
|
+
case "exitNativeFullscreen":
|
|
125
|
+
exitNativeFullscreen();
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
const handlers = messageHandlers.get(data.type);
|
|
129
|
+
if (handlers) {
|
|
130
|
+
for (const h of handlers) {
|
|
131
|
+
try {
|
|
132
|
+
h(data.payload);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
console.warn("[widget-provider] message handler error:", e);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
globalThis.addEventListener("message", handleMessage);
|
|
141
|
+
// append to DOM
|
|
142
|
+
const appendTarget = parentContainer || document.body;
|
|
143
|
+
appendTarget.appendChild(container);
|
|
144
|
+
// trigger button
|
|
145
|
+
const triggerOpts = options.trigger;
|
|
146
|
+
let triggerEl = null;
|
|
147
|
+
if (triggerOpts) {
|
|
148
|
+
triggerEl = document.createElement("button");
|
|
149
|
+
Object.assign(triggerEl.style, TRIGGER_BASE);
|
|
150
|
+
if (typeof triggerOpts === "object" && triggerOpts.style) {
|
|
151
|
+
Object.assign(triggerEl.style, triggerOpts.style);
|
|
152
|
+
}
|
|
153
|
+
const content = typeof triggerOpts === "object" && triggerOpts.content
|
|
154
|
+
? triggerOpts.content
|
|
155
|
+
: DEFAULT_TRIGGER_ICON;
|
|
156
|
+
triggerEl.innerHTML = content;
|
|
157
|
+
if (visible) {
|
|
158
|
+
triggerEl.style.display = "none";
|
|
159
|
+
}
|
|
160
|
+
triggerEl.addEventListener("click", () => show());
|
|
161
|
+
appendTarget.appendChild(triggerEl);
|
|
162
|
+
}
|
|
163
|
+
// API
|
|
164
|
+
function show() {
|
|
165
|
+
if (state.get().destroyed)
|
|
166
|
+
return;
|
|
167
|
+
if (anim) {
|
|
168
|
+
Object.assign(container.style, anim.hidden);
|
|
169
|
+
container.style.display = "";
|
|
170
|
+
container.offsetHeight; // force reflow
|
|
171
|
+
Object.assign(container.style, anim.visible);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
container.style.display = "";
|
|
175
|
+
}
|
|
176
|
+
if (triggerEl)
|
|
177
|
+
triggerEl.style.display = "none";
|
|
178
|
+
state.update((s) => ({ ...s, visible: true }));
|
|
179
|
+
}
|
|
180
|
+
function hide() {
|
|
181
|
+
if (state.get().destroyed)
|
|
182
|
+
return;
|
|
183
|
+
if (triggerEl)
|
|
184
|
+
triggerEl.style.display = "";
|
|
185
|
+
state.update((s) => ({ ...s, visible: false }));
|
|
186
|
+
if (anim) {
|
|
187
|
+
Object.assign(container.style, anim.hidden);
|
|
188
|
+
const done = () => {
|
|
189
|
+
if (!state.get().visible) {
|
|
190
|
+
container.style.display = "none";
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
container.addEventListener("transitionend", done, {
|
|
194
|
+
once: true,
|
|
195
|
+
});
|
|
196
|
+
setTimeout(done, 250);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
container.style.display = "none";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function toggle() {
|
|
203
|
+
if (state.get().visible)
|
|
204
|
+
hide();
|
|
205
|
+
else
|
|
206
|
+
show();
|
|
207
|
+
}
|
|
208
|
+
function setPreset(preset) {
|
|
209
|
+
if (state.get().destroyed)
|
|
210
|
+
return;
|
|
211
|
+
if (!(preset in STYLE_PRESETS))
|
|
212
|
+
return;
|
|
213
|
+
container.style.cssText = "";
|
|
214
|
+
applyPreset(container, preset, styleOverrides);
|
|
215
|
+
if (anim) {
|
|
216
|
+
container.style.transition = anim.transition;
|
|
217
|
+
}
|
|
218
|
+
if (!state.get().visible) {
|
|
219
|
+
container.style.display = "none";
|
|
220
|
+
if (anim) {
|
|
221
|
+
Object.assign(container.style, anim.hidden);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
state.update((s) => ({ ...s, preset }));
|
|
225
|
+
}
|
|
226
|
+
function maximize() {
|
|
227
|
+
setPreset("fullscreen");
|
|
228
|
+
}
|
|
229
|
+
function minimize() {
|
|
230
|
+
setPreset(initialPreset);
|
|
231
|
+
}
|
|
232
|
+
function requestNativeFullscreen() {
|
|
233
|
+
if (state.get().destroyed)
|
|
234
|
+
return Promise.resolve();
|
|
235
|
+
return iframe.requestFullscreen();
|
|
236
|
+
}
|
|
237
|
+
function exitNativeFullscreen() {
|
|
238
|
+
if (!document.fullscreenElement)
|
|
239
|
+
return Promise.resolve();
|
|
240
|
+
return document.exitFullscreen();
|
|
241
|
+
}
|
|
242
|
+
function destroy() {
|
|
243
|
+
if (state.get().destroyed)
|
|
244
|
+
return;
|
|
245
|
+
globalThis.removeEventListener("message", handleMessage);
|
|
246
|
+
messageHandlers.clear();
|
|
247
|
+
iframe.src = "about:blank";
|
|
248
|
+
container.remove();
|
|
249
|
+
triggerEl?.remove();
|
|
250
|
+
state.update((s) => ({
|
|
251
|
+
visible: false,
|
|
252
|
+
ready: false,
|
|
253
|
+
destroyed: true,
|
|
254
|
+
preset: s.preset,
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
function send(type, payload) {
|
|
258
|
+
if (state.get().destroyed)
|
|
259
|
+
return;
|
|
260
|
+
iframe.contentWindow?.postMessage({ type: `${MSG_PREFIX}${type}`, payload }, origins[0] || "*");
|
|
261
|
+
}
|
|
262
|
+
function onMessage(type, handler) {
|
|
263
|
+
const prefixedType = `${MSG_PREFIX}${type}`;
|
|
264
|
+
if (!messageHandlers.has(prefixedType)) {
|
|
265
|
+
messageHandlers.set(prefixedType, new Set());
|
|
266
|
+
}
|
|
267
|
+
messageHandlers.get(prefixedType).add(handler);
|
|
268
|
+
return () => {
|
|
269
|
+
messageHandlers.get(prefixedType)?.delete(handler);
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
show,
|
|
274
|
+
hide,
|
|
275
|
+
toggle,
|
|
276
|
+
destroy,
|
|
277
|
+
setPreset,
|
|
278
|
+
maximize,
|
|
279
|
+
minimize,
|
|
280
|
+
requestNativeFullscreen,
|
|
281
|
+
exitNativeFullscreen,
|
|
282
|
+
send,
|
|
283
|
+
onMessage,
|
|
284
|
+
subscribe: state.subscribe,
|
|
285
|
+
get: state.get,
|
|
286
|
+
get iframe() {
|
|
287
|
+
return iframe;
|
|
288
|
+
},
|
|
289
|
+
get container() {
|
|
290
|
+
return container;
|
|
291
|
+
},
|
|
292
|
+
get trigger() {
|
|
293
|
+
return triggerEl;
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Single-function library that creates an iframe-based widget embedded in a host page. The host communicates with the iframe via `postMessage`, with origin validation and namespaced message types.
|
|
6
|
+
|
|
7
|
+
## Component Map
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Host Page (provideWidget caller)
|
|
11
|
+
│
|
|
12
|
+
├── Container <div> ← styled by preset (float/fullscreen/inline)
|
|
13
|
+
│ └── <iframe> ← loads widgetUrl, sandboxed
|
|
14
|
+
│
|
|
15
|
+
├── Trigger <button> ← optional, auto-toggles visibility
|
|
16
|
+
│
|
|
17
|
+
└── State Store ← @marianmeres/store (visible, ready, destroyed, preset)
|
|
18
|
+
└── postMessage listener ← origin-validated, prefix-filtered
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Data Flow
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Host → iframe: widget.send(type, payload) → postMessage with MSG_PREFIX
|
|
25
|
+
iframe → Host: postMessage with MSG_PREFIX → handleMessage → built-in handlers + onMessage callbacks
|
|
26
|
+
|
|
27
|
+
Built-in control messages (from iframe):
|
|
28
|
+
ready, maximize, minimize, hide, close, setPreset, nativeFullscreen, exitNativeFullscreen
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Key Files
|
|
32
|
+
|
|
33
|
+
| File | Purpose |
|
|
34
|
+
|------|---------|
|
|
35
|
+
| `src/widget-provider.ts` | `provideWidget()` factory — creates DOM, wires messaging, returns API |
|
|
36
|
+
| `src/types.ts` | All types, interfaces, `MSG_PREFIX` constant |
|
|
37
|
+
| `src/style-presets.ts` | CSS preset objects, animation configs, apply functions |
|
|
38
|
+
| `src/mod.ts` | Public barrel export |
|
|
39
|
+
|
|
40
|
+
## External Dependencies
|
|
41
|
+
|
|
42
|
+
| Dependency | Purpose |
|
|
43
|
+
|------------|---------|
|
|
44
|
+
| `@marianmeres/store` | Reactive state store (Svelte-compatible subscribe pattern) |
|
|
45
|
+
|
|
46
|
+
## Security Boundaries
|
|
47
|
+
|
|
48
|
+
- **Origin validation**: `resolveAllowedOrigins()` derives from `widgetUrl` or uses explicit config. `isOriginAllowed()` checks incoming messages.
|
|
49
|
+
- **Iframe sandbox**: Defaults to `allow-scripts allow-same-origin`. Configurable via `sandbox` option.
|
|
50
|
+
- **Message namespace**: All messages prefixed with `@@__widget_provider__@@` to avoid collisions.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Conventions
|
|
2
|
+
|
|
3
|
+
## File Organisation
|
|
4
|
+
|
|
5
|
+
- Types and interfaces → `src/types.ts`
|
|
6
|
+
- Style/CSS logic → `src/style-presets.ts`
|
|
7
|
+
- Core implementation → `src/widget-provider.ts`
|
|
8
|
+
- Public exports → `src/mod.ts` (barrel)
|
|
9
|
+
|
|
10
|
+
## Naming
|
|
11
|
+
|
|
12
|
+
- Factory function: `provideWidget()` (not `createWidget`)
|
|
13
|
+
- Type names: PascalCase (`WidgetProviderOptions`, `StylePreset`)
|
|
14
|
+
- Constants: UPPER_SNAKE (`MSG_PREFIX`, `STYLE_PRESETS`, `ANIMATE_PRESETS`)
|
|
15
|
+
- Internal helpers: camelCase, not exported from `mod.ts`
|
|
16
|
+
|
|
17
|
+
## Patterns
|
|
18
|
+
|
|
19
|
+
### Message Protocol
|
|
20
|
+
All messages use `WidgetMessage` envelope with `MSG_PREFIX`:
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// Sending
|
|
24
|
+
send("myEvent", { data: 123 });
|
|
25
|
+
// Wire format: { type: "@@__widget_provider__@@myEvent", payload: { data: 123 } }
|
|
26
|
+
|
|
27
|
+
// Receiving
|
|
28
|
+
onMessage("myEvent", (payload) => { ... });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Style Presets
|
|
32
|
+
Presets are plain `Partial<CSSStyleDeclaration>` objects applied via `Object.assign`:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// Adding a new preset:
|
|
36
|
+
// 1. Add to StylePreset union in types.ts
|
|
37
|
+
// 2. Create CSS object in style-presets.ts
|
|
38
|
+
// 3. Add to STYLE_PRESETS record
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### State Management
|
|
42
|
+
State is a `@marianmeres/store` instance with `WidgetState` shape. Subscribe with Svelte-compatible pattern:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
widget.subscribe((state) => { /* reactive */ });
|
|
46
|
+
widget.get(); // snapshot
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Anti-Patterns
|
|
50
|
+
|
|
51
|
+
- Do not create multiple `provideWidget()` instances targeting the same container
|
|
52
|
+
- Do not call API methods after `destroy()`
|
|
53
|
+
- Do not use `"*"` for `allowedOrigin` in production
|
|
54
|
+
|
|
55
|
+
## Testing
|
|
56
|
+
|
|
57
|
+
- Pure utility functions (`resolveAllowedOrigins`, `isOriginAllowed`) are tested directly
|
|
58
|
+
- DOM-dependent `provideWidget()` requires browser environment (not tested in Deno unit tests)
|
|
59
|
+
- Run: `deno test`
|
|
60
|
+
|
|
61
|
+
## Formatting
|
|
62
|
+
|
|
63
|
+
- Tabs for indentation
|
|
64
|
+
- 90 char line width
|
|
65
|
+
- Run `deno fmt` before committing
|
package/docs/tasks.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Tasks
|
|
2
|
+
|
|
3
|
+
## Add a New Style Preset
|
|
4
|
+
|
|
5
|
+
### Steps
|
|
6
|
+
1. Add preset name to `StylePreset` union in `src/types.ts`
|
|
7
|
+
2. Create CSS object in `src/style-presets.ts` (extend `BASE_CONTAINER`)
|
|
8
|
+
3. Add to `STYLE_PRESETS` record in `src/style-presets.ts`
|
|
9
|
+
|
|
10
|
+
### Template
|
|
11
|
+
```typescript
|
|
12
|
+
// src/types.ts
|
|
13
|
+
export type StylePreset = "float" | "fullscreen" | "inline" | "new-preset";
|
|
14
|
+
|
|
15
|
+
// src/style-presets.ts
|
|
16
|
+
const PRESET_NEW: CSSProps = {
|
|
17
|
+
...BASE_CONTAINER,
|
|
18
|
+
// CSS properties
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const STYLE_PRESETS: Record<StylePreset, CSSProps> = {
|
|
22
|
+
// ... existing
|
|
23
|
+
"new-preset": PRESET_NEW,
|
|
24
|
+
};
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Checklist
|
|
28
|
+
- [ ] Type union updated
|
|
29
|
+
- [ ] CSS object created
|
|
30
|
+
- [ ] STYLE_PRESETS record updated
|
|
31
|
+
- [ ] `deno test` passes
|
|
32
|
+
|
|
33
|
+
## Add a New Animation Preset
|
|
34
|
+
|
|
35
|
+
### Steps
|
|
36
|
+
1. Add preset name to `AnimatePreset` union in `src/types.ts`
|
|
37
|
+
2. Add `AnimateConfig` entry in `ANIMATE_PRESETS` in `src/style-presets.ts`
|
|
38
|
+
|
|
39
|
+
### Checklist
|
|
40
|
+
- [ ] Type union updated
|
|
41
|
+
- [ ] Preset config added with `transition`, `hidden`, `visible` properties
|
|
42
|
+
|
|
43
|
+
## Add a New Built-in Control Message
|
|
44
|
+
|
|
45
|
+
### Steps
|
|
46
|
+
1. Add `case` to `switch (bareType)` in `handleMessage()` in `src/widget-provider.ts`
|
|
47
|
+
2. Implement handler function if needed
|
|
48
|
+
|
|
49
|
+
### Template
|
|
50
|
+
```typescript
|
|
51
|
+
case "myControl":
|
|
52
|
+
myControlFunction();
|
|
53
|
+
break;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Checklist
|
|
57
|
+
- [ ] Case added to switch
|
|
58
|
+
- [ ] Handler implemented
|
|
59
|
+
- [ ] No-op if destroyed
|
|
60
|
+
|
|
61
|
+
## Build and Publish
|
|
62
|
+
|
|
63
|
+
### Steps
|
|
64
|
+
1. Run `deno test` to verify
|
|
65
|
+
2. Run `deno task release` to bump version
|
|
66
|
+
3. Run `deno task publish` to publish to JSR + npm
|
|
67
|
+
|
|
68
|
+
### Checklist
|
|
69
|
+
- [ ] Tests pass
|
|
70
|
+
- [ ] Version bumped
|
|
71
|
+
- [ ] Published to both registries
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marianmeres/widget-provider",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/mod.js",
|
|
6
|
+
"types": "dist/mod.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/mod.d.ts",
|
|
10
|
+
"import": "./dist/mod.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"author": "Marian Meres",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@marianmeres/store": "^2.4.4"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/marianmeres/widget-provider.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/marianmeres/widget-provider/issues"
|
|
24
|
+
}
|
|
25
|
+
}
|