Skip to main content

vanilla-widget Example

examples/vanilla-widget is the official example for the vanilla-ts template: plain TypeScript, no framework, zero Vite configuration. Its structure matches the project generated by npx create-patab-widget my-widget --template vanilla-ts.

Directory Layout

vanilla-widget/
├── patab.manifest.json
├── schema/patab.manifest.schema.json
├── package.json
├── tsconfig.json
├── assets/
│ ├── icon.png
│ └── screenshots/preview.png
└── src/
├── env.d.ts # /// <reference types="vite/client" />
├── widget.spec.ts # Mock 宿主单元测试
└── surfaces/
├── widget.html / widget.ts
├── detail.html / detail.ts
└── settings.html / settings.ts

Manifest Highlights

{
"id": "com.example.examples-vanilla-widget",
"surfaces": {
"widget": { "entry": "surfaces/widget.html", "title": { "default": "组件", "...": "..." } },
"detail": { "entry": "surfaces/detail.html", "title": { "...": "..." }, "modalSize": "medium" },
"settings": { "entry": "surfaces/settings.html", "title": { "...": "..." }, "modalSize": "small" }
},
"sizes": [{ "w": 2, "h": 2 }, { "w": 3, "h": 2 }],
"defaultSize": { "w": 2, "h": 2 },
"variants": [
{ "id": "compact", "supportedSizes": [{ "w": 2, "h": 2 }], "...": "..." },
{ "id": "expanded", "supportedSizes": [{ "w": 3, "h": 2 }], "...": "..." }
],
"defaultVariant": "compact",
"permissions": { "optional": ["todos.read"] }
}

It demonstrates three surfaces (a widget tile plus detail/settings modals), two sizes, two variants, and how to declare the optional permission todos.read.

Entry HTML

Each surface has a minimal HTML skeleton containing only a mount point and a module script:

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>widget</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="./widget.ts"></script>
</body>
</html>

At packing time the CLI inlines the build output of ./widget.ts into this HTML, producing a fully self-contained surfaces/widget.html.

Entry TS Walkthrough

import '@patab/widget-sdk/theme.css'
import { connectPatabWidgetClient, createWidgetApi, type WidgetThemeChangedEvent } from '@patab/widget-sdk'

async function start(): Promise<void> {
// 1. 连接宿主:等待 MessagePort 握手完成
const client = await connectPatabWidgetClient()
const api = createWidgetApi(client)

// 2. 读取上下文:surface 名、variant、主题、权限快照
const context = await api.context.get()

const root = document.querySelector<HTMLElement>('#app')
if (!root) return
root.innerHTML = '<h1>' + context.surface + '</h1><p>' + (context.variantId ?? 'default') + '</p><button class="pt-button">保存示例</button>'

// 3. 实例存储:点击按钮把当前 surface 写入实例私有存储
root.querySelector('button')?.addEventListener('click', () => {
void api.storage.set('lastSurface', context.surface)
})

// 4. 事件订阅:主题变化时更新根节点标记
api.on<WidgetThemeChangedEvent>('themeChanged', (event) => {
document.documentElement.dataset.theme = event.theme
})

// 5. 可选权限:先检查 granted 快照再调用,未授权时静默降级
if (context.permissions.granted.includes('todos.read')) {
void api.todos.list().catch(() => undefined)
}
}

void start()

Point by point:

  1. Connection: connectPatabWidgetClient() is the only entry point; the widget never touches window.parent/postMessage.
  2. Context: context.get() provides the environment information needed for rendering (here it renders the surface name and variant ID).
  3. Storage: storage.set demonstrates instance-private storage — data from different instances (the same widget added to the grid multiple times) is mutually invisible.
  4. Theme: importing theme.css provides the pt-* semantic classes (the button uses .pt-button), and subscribing to themeChanged enables custom responses. Note that pt-card/pt-title/pt-muted in the example are not SDK contract classes; in real projects use the semantic classes listed in theme.css.
  5. Permission degradation: todos.read is an optional permission — the user may not have granted it or may revoke it at any time. Checking the snapshot first and then falling back with .catch() is the recommended pattern (calls after revocation return PERMISSION_DENIED).

Unit Tests

import { expect, it } from 'vitest'
import { createWidgetMockHost } from '@patab/widget-sdk/testing'

it('读取 Mock 存储', async () => {
const host = createWidgetMockHost({ storage: { greeting: 'PaTab' } })
await expect(host.api.storage.get('greeting')).resolves.toEqual({ found: true, value: 'PaTab' })
host.close()
})

Uses the Mock Host to inject initial storage and asserts read results over the real RPC protocol. Run pnpm test (vitest run).

Running Locally

cd examples/vanilla-widget
pnpm install
pnpm dev # Open the real PaTab development host and mount the widget
pnpm check && pnpm run pack && pnpm exec patab-widget inspect dist/*.patab.zip