ui-next poc - widgets
This commit is contained in:
parent
3e3efbe4d4
commit
5a43ce1cd1
@ -30,7 +30,7 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open the app and use the header or **`/examples`** to walk through interactive migration examples (dynamic params, splat, typed search, loaders, redirects, 404, scroll proofs, lazy routes).
|
||||
Open the app and use the header or **`/examples`** to walk through interactive migration examples (dynamic params, splat, typed search, loaders, redirects, 404, scroll proofs, lazy routes). For the **widget registry, plugins, and extension slots** scaffold (aligned with `packages/ui/docs/widgets-api.md`), open **`/examples/widgets-system`** or browse [`src/widgets/`](src/widgets/index.ts).
|
||||
|
||||
```bash
|
||||
npm run build # tsc --noEmit && vite build
|
||||
@ -46,6 +46,7 @@ npm run preview # production build preview
|
||||
| [`src/router.tsx`](src/router.tsx) | Root layout, `createRouter`, scroll restoration, global 404 |
|
||||
| [`src/main.tsx`](src/main.tsx) | `RouterProvider`, dev-only `router.subscribe` stub for analytics |
|
||||
| [`src/examples/migration/`](src/examples/migration/) | Runnable demos + [`MIGRATION_EXAMPLES.md`](src/examples/migration/MIGRATION_EXAMPLES.md) |
|
||||
| [`src/widgets/`](src/widgets/index.ts) | Widget registry, `PluginManager`, `PluginAPI`, hooks, `ExtensionSlot`; demo at `/examples/widgets-system` · [Architecture diagrams](docs/widgets.md) |
|
||||
| [`vite.config.ts`](vite.config.ts) | Vite + React; comment shows where `TanStackRouterVite` plugs in for file-based routes later |
|
||||
| [`tsconfig.json`](tsconfig.json) | Extends `../typescript-config/base.json`, tuned for Vite + `bundler` resolution |
|
||||
|
||||
|
||||
178
packages/ui-next/docs/widgets.md
Normal file
178
packages/ui-next/docs/widgets.md
Normal file
@ -0,0 +1,178 @@
|
||||
# Widget system (`@polymech/ui-next`)
|
||||
|
||||
This document describes the **scaffold** under [`src/widgets/`](../src/widgets/index.ts): registry, plugins, hooks, and extension slots. It implements a subset of the full contract in [`packages/ui/docs/widgets-api.md`](../../ui/docs/widgets-api.md) (Polymech UI).
|
||||
|
||||
---
|
||||
|
||||
## 1. High-level architecture
|
||||
|
||||
Plugins run at bootstrap, receive a **capability-limited `PluginAPI`**, and register widgets, hooks, and slot components. The host UI renders **`ExtensionSlot`** at fixed **slot IDs**; injected components appear there without the core editor importing plugin packages directly.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph bootstrap["Bootstrap"]
|
||||
Boot([bootWidgetSystem])
|
||||
Boot --> PM[PluginManager.register]
|
||||
end
|
||||
|
||||
subgraph plugins["Plugins"]
|
||||
P1[coreWidgetsPlugin]
|
||||
P2[demoToolbarPlugin]
|
||||
end
|
||||
|
||||
subgraph registries["Registries"]
|
||||
WR[(WidgetRegistry)]
|
||||
HR[(HookRegistry)]
|
||||
SR[(SlotRegistry)]
|
||||
end
|
||||
|
||||
subgraph react["React tree"]
|
||||
ES[ExtensionSlot slotId]
|
||||
W[Widget components]
|
||||
end
|
||||
|
||||
PM --> P1
|
||||
PM --> P2
|
||||
P1 --> API[PluginAPI]
|
||||
P2 --> API
|
||||
API --> WR
|
||||
API --> HR
|
||||
API --> SR
|
||||
WR --> W
|
||||
SR --> ES
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Plugin lifecycle
|
||||
|
||||
Registration order matters for **`requires`**: dependencies must already be registered. Higher **`priority`** runs first for ordered hooks (see §3).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App
|
||||
participant PM as PluginManager
|
||||
participant API as PluginAPI
|
||||
participant Plugin
|
||||
|
||||
App->>PM: register(plugin)
|
||||
PM->>PM: validate requires deps
|
||||
PM->>API: createPluginAPI(scoped to plugin id)
|
||||
PM->>Plugin: setup(api)
|
||||
Plugin->>API: registerWidget / addHook / injectSlot
|
||||
API-->>Plugin: void
|
||||
PM->>PM: plugins.set(id, plugin)
|
||||
|
||||
Note over App,Plugin: unregister(pluginId)
|
||||
App->>PM: unregister(id)
|
||||
PM->>Plugin: teardown()
|
||||
PM->>PM: removeHooksForPlugin, clearSlot entries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Hook pipeline (conceptual)
|
||||
|
||||
Hook handlers are stored with **plugin id** and **priority**. When the host calls **`HookRegistry.runSync`**, handlers run in **descending priority** (see widgets-api §13). Not every `HookName` is wired in the scaffold yet; the registry is ready for editor/layout integration.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph pipeline["editor:widgetPalette (example)"]
|
||||
H1["Handler priority 100"]
|
||||
H2["Handler priority 10"]
|
||||
H3["Handler priority 0"]
|
||||
end
|
||||
|
||||
InputIn["widgets[] in"] --> H1
|
||||
H1 --> H2
|
||||
H2 --> H3
|
||||
H3 --> OutputOut["widgets[] out"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Extension slots
|
||||
|
||||
**`PluginAPI.injectSlot(slotId, Component)`** appends a React component to that slot. **`ExtensionSlot`** subscribes to **`SlotRegistry`** via **`useSyncExternalStore`** and re-renders when injections change. Multiple plugins (or multiple calls from one plugin) can add entries; each entry gets a stable **`entryId`**.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph chrome["Editor chrome (conceptual)"]
|
||||
TBStart["editor:toolbar:start"]
|
||||
TBEnd["editor:toolbar:end"]
|
||||
Side["editor:sidebar:top / …"]
|
||||
end
|
||||
|
||||
Plugin["Plugin setup"]
|
||||
Plugin --> Inject[injectSlot]
|
||||
Inject --> SR[(SlotRegistry)]
|
||||
SR --> ES[ExtensionSlot]
|
||||
ES --> DOM[Rendered React tree]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow: from plugin to screen
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph pluginSide["Plugin"]
|
||||
R["registerWidget(metadata.id)"]
|
||||
I["injectSlot(editor:toolbar:end)"]
|
||||
end
|
||||
|
||||
subgraph state["State"]
|
||||
WR[(WidgetRegistry)]
|
||||
SR[(SlotRegistry)]
|
||||
end
|
||||
|
||||
subgraph ui["UI"]
|
||||
Palette["Future: palette reads registry"]
|
||||
Slot[ExtensionSlot]
|
||||
end
|
||||
|
||||
R --> WR
|
||||
I --> SR
|
||||
WR -.-> Palette
|
||||
SR --> Slot
|
||||
```
|
||||
|
||||
The demo page [`WidgetSystemDemoPage`](../src/examples/widgets/WidgetSystemDemoPage.tsx) reads **`widgetRegistry.getAll()`** and mounts **`ExtensionSlot`** for **`editor:toolbar:end`**, plus a direct **`TextBlockWidget`** preview with mock **`BaseWidgetProps`**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Module map (this package)
|
||||
|
||||
| Module | Role |
|
||||
|--------|------|
|
||||
| [`types.ts`](../src/widgets/types.ts) | `WidgetDefinition`, `WidgetPlugin`, `PluginAPI`, `HookName`, `SlotId`, … |
|
||||
| [`WidgetRegistry`](../src/widgets/registry/WidgetRegistry.ts) | CRUD for widget definitions |
|
||||
| [`HookRegistry`](../src/widgets/plugins/HookRegistry.ts) | Hook lists + `runSync` |
|
||||
| [`SlotRegistry`](../src/widgets/plugins/SlotRegistry.ts) | Slot entries + `subscribe` |
|
||||
| [`createPluginAPI`](../src/widgets/plugins/createPluginAPI.ts) | Facade passed into `plugin.setup` |
|
||||
| [`PluginManager`](../src/widgets/plugins/PluginManager.ts) | `register` / `unregister` |
|
||||
| [`system.ts`](../src/widgets/system.ts) | Process-wide singletons for the POC |
|
||||
| [`ExtensionSlot`](../src/widgets/slots/ExtensionSlot.tsx) | Renders slot content |
|
||||
| [`boot.ts`](../src/widgets/bootstrap/boot.ts) | `bootWidgetSystem()` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Relationship to the full widgets API
|
||||
|
||||
| Full doc topic | Status in this scaffold |
|
||||
|----------------|-------------------------|
|
||||
| `BaseWidgetProps`, metadata, `configSchema` | Types + sample `text-block` |
|
||||
| `PluginAPI` (widgets, hooks, slots) | Implemented (minimal `getStore` / `subscribe`) |
|
||||
| Container types, `registerContainerType` | Not implemented — extend `PluginAPI` when needed |
|
||||
| Real `LayoutStore` (Zustand) | Stub `layoutStore` object |
|
||||
| Nested layouts, export, i18n | Not implemented |
|
||||
|
||||
For the authoritative interface proposal, keep using **`packages/ui/docs/widgets-api.md`**.
|
||||
|
||||
---
|
||||
|
||||
## 8. Try it
|
||||
|
||||
1. Run the app (`npm run dev` in `packages/ui-next`).
|
||||
2. Open **`/examples/widgets-system`** (or **Migration examples → widgets + plugins**).
|
||||
3. Confirm the toolbar slot pill and the **`text-block`** registry entry after **`bootWidgetSystem()`** runs.
|
||||
@ -20,6 +20,7 @@ import {
|
||||
type MigrationSearch,
|
||||
} from "@/examples/migration/searchSchema";
|
||||
import { ScrollRestorationDemo } from "@/examples/migration/ScrollRestorationDemo";
|
||||
import { WidgetSystemDemoPage } from "@/examples/widgets/WidgetSystemDemoPage";
|
||||
|
||||
export function buildMigrationExamplesBranch(rootRoute: AnyRoute) {
|
||||
function ExamplesIndex() {
|
||||
@ -90,6 +91,9 @@ export function buildMigrationExamplesBranch(rootRoute: AnyRoute) {
|
||||
<Link to="/examples/lazy-scroll-restoration" className="link">
|
||||
lazy + scroll
|
||||
</Link>
|
||||
<Link to="/examples/widgets-system" className="link">
|
||||
widgets + plugins
|
||||
</Link>
|
||||
<Link to="/examples/loader-demo" className="link">
|
||||
loader
|
||||
</Link>
|
||||
@ -284,6 +288,12 @@ export function buildMigrationExamplesBranch(rootRoute: AnyRoute) {
|
||||
),
|
||||
});
|
||||
|
||||
const widgetSystemDemoRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "widgets-system",
|
||||
component: WidgetSystemDemoPage,
|
||||
});
|
||||
|
||||
const loaderDemoRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "loader-demo",
|
||||
@ -364,6 +374,7 @@ export function buildMigrationExamplesBranch(rootRoute: AnyRoute) {
|
||||
searchRoute,
|
||||
scrollRestorationDemoRoute,
|
||||
lazyScrollRestorationRoute,
|
||||
widgetSystemDemoRoute,
|
||||
loaderDemoRoute,
|
||||
navigateDemoRoute,
|
||||
redirectRoute,
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { bootWidgetSystem } from "@/widgets/bootstrap/boot";
|
||||
import { ExtensionSlot } from "@/widgets/slots/ExtensionSlot";
|
||||
import { widgetRegistry } from "@/widgets/system";
|
||||
import { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
|
||||
import type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget";
|
||||
|
||||
const demoProps: TextBlockWidgetProps = {
|
||||
widgetInstanceId: "demo-instance",
|
||||
widgetDefId: "text-block",
|
||||
isEditMode: false,
|
||||
title: "Registered widget preview",
|
||||
body: "Props match BaseWidgetProps + text-block fields (see packages/ui/docs/widgets-api.md).",
|
||||
async onPropsChange() {},
|
||||
};
|
||||
|
||||
export function WidgetSystemDemoPage() {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void bootWidgetSystem().then(() => setReady(true));
|
||||
}, []);
|
||||
|
||||
const defs = ready ? widgetRegistry.getAll() : [];
|
||||
|
||||
return (
|
||||
<section className="nested">
|
||||
<h1>Widget system scaffold</h1>
|
||||
<p className="muted">
|
||||
Implements registry, <code>PluginManager</code>, <code>PluginAPI</code>, hooks, and{" "}
|
||||
<code>ExtensionSlot</code> per <code>packages/ui/docs/widgets-api.md</code> §1–2, §13.
|
||||
</p>
|
||||
|
||||
<div className="widget-demo-toolbar">
|
||||
<span className="muted">Slot preview (edit chrome)</span>
|
||||
<div className="widget-demo-toolbar-inner">
|
||||
<ExtensionSlot
|
||||
slotId="editor:toolbar:end"
|
||||
pageId="widget-system-demo"
|
||||
isEditMode
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className="panel">
|
||||
<h2>Registry ({ready ? defs.length : "…"})</h2>
|
||||
{ready ? (
|
||||
<ul>
|
||||
{defs.map((d) => (
|
||||
<li key={d.metadata.id}>
|
||||
<code>{d.metadata.id}</code> — {d.metadata.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>Bootstrapping plugins…</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<TextBlockWidget {...demoProps} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -166,3 +166,32 @@ code {
|
||||
.lazy-chunk-intro {
|
||||
border-left: 4px solid #6366f1;
|
||||
}
|
||||
|
||||
.widget-text-block h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.widget-demo-toolbar {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.widget-demo-toolbar-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.widget-slot-pill {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
background: #312e81;
|
||||
color: #e0e7ff;
|
||||
}
|
||||
|
||||
13
packages/ui-next/src/widgets/bootstrap/boot.ts
Normal file
13
packages/ui-next/src/widgets/bootstrap/boot.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { demoToolbarPlugin } from "@/widgets/bootstrap/demoToolbarPlugin";
|
||||
import { coreWidgetsPlugin } from "@/widgets/bootstrap/corePlugin";
|
||||
import { pluginManager } from "@/widgets/system";
|
||||
|
||||
let booted = false;
|
||||
|
||||
/** Idempotent — safe to call from a route or app root. */
|
||||
export async function bootWidgetSystem(): Promise<void> {
|
||||
if (booted) return;
|
||||
booted = true;
|
||||
await pluginManager.register(coreWidgetsPlugin);
|
||||
await pluginManager.register(demoToolbarPlugin);
|
||||
}
|
||||
34
packages/ui-next/src/widgets/bootstrap/corePlugin.tsx
Normal file
34
packages/ui-next/src/widgets/bootstrap/corePlugin.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { WidgetPlugin } from "@/widgets/types";
|
||||
|
||||
import { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
|
||||
import type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget";
|
||||
|
||||
/**
|
||||
* Registers baseline widgets — mirrors wrapping `registerWidgets()` as a plugin (widgets-api §13).
|
||||
*/
|
||||
export const coreWidgetsPlugin: WidgetPlugin = {
|
||||
id: "polymech:core-widgets",
|
||||
name: "Core widgets",
|
||||
version: "0.0.0",
|
||||
priority: 100,
|
||||
setup(api) {
|
||||
api.registerWidget<TextBlockWidgetProps>({
|
||||
component: TextBlockWidget,
|
||||
metadata: {
|
||||
id: "text-block",
|
||||
name: "Text block",
|
||||
category: "display",
|
||||
description: "Heading + body (scaffold)",
|
||||
tags: ["text", "core"],
|
||||
defaultProps: {
|
||||
title: "Hello",
|
||||
body: "Widget registry + plugin bootstrap.",
|
||||
},
|
||||
configSchema: {
|
||||
title: { type: "text", label: "Title", group: "Content" },
|
||||
body: { type: "markdown", label: "Body", group: "Content" },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
24
packages/ui-next/src/widgets/bootstrap/demoToolbarPlugin.tsx
Normal file
24
packages/ui-next/src/widgets/bootstrap/demoToolbarPlugin.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import type { WidgetPlugin } from "@/widgets/types";
|
||||
|
||||
/**
|
||||
* Second plugin: depends on core, injects `editor:toolbar:end` (widgets-api §13 slots).
|
||||
*/
|
||||
export const demoToolbarPlugin: WidgetPlugin = {
|
||||
id: "polymech:demo-toolbar",
|
||||
name: "Demo toolbar slot",
|
||||
version: "0.0.0",
|
||||
priority: 10,
|
||||
requires: ["polymech:core-widgets"],
|
||||
setup(api) {
|
||||
api.injectSlot("editor:toolbar:end", function DemoToolbarEnd(props) {
|
||||
return (
|
||||
<span
|
||||
className="widget-slot-pill"
|
||||
title="Injected via PluginAPI.injectSlot"
|
||||
>
|
||||
editor:toolbar:end · {props.pageId}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
41
packages/ui-next/src/widgets/index.ts
Normal file
41
packages/ui-next/src/widgets/index.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Widget / plugin / extension-slot scaffolding (see `packages/ui/docs/widgets-api.md`).
|
||||
*/
|
||||
export type {
|
||||
BaseWidgetProps,
|
||||
ConfigField,
|
||||
HookContext,
|
||||
HookHandler,
|
||||
HookName,
|
||||
LayoutStore,
|
||||
PluginAPI,
|
||||
SlotId,
|
||||
SlotProps,
|
||||
WidgetDefinition,
|
||||
WidgetInstance,
|
||||
WidgetMetadata,
|
||||
WidgetPlugin,
|
||||
WidgetWrapper,
|
||||
} from "@/widgets/types";
|
||||
|
||||
export { WidgetRegistry } from "@/widgets/registry/WidgetRegistry";
|
||||
export { HookRegistry } from "@/widgets/plugins/HookRegistry";
|
||||
export { SlotRegistry } from "@/widgets/plugins/SlotRegistry";
|
||||
export { PluginManager } from "@/widgets/plugins/PluginManager";
|
||||
export { createPluginAPI } from "@/widgets/plugins/createPluginAPI";
|
||||
export { ExtensionSlot } from "@/widgets/slots/ExtensionSlot";
|
||||
|
||||
export {
|
||||
layoutStore,
|
||||
widgetRegistry,
|
||||
hookRegistry,
|
||||
slotRegistry,
|
||||
pluginManager,
|
||||
} from "@/widgets/system";
|
||||
|
||||
export { bootWidgetSystem } from "@/widgets/bootstrap/boot";
|
||||
export { coreWidgetsPlugin } from "@/widgets/bootstrap/corePlugin";
|
||||
export { demoToolbarPlugin } from "@/widgets/bootstrap/demoToolbarPlugin";
|
||||
|
||||
export { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
|
||||
export type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget";
|
||||
77
packages/ui-next/src/widgets/plugins/HookRegistry.ts
Normal file
77
packages/ui-next/src/widgets/plugins/HookRegistry.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import type {
|
||||
HookContext,
|
||||
HookHandler,
|
||||
HookName,
|
||||
LayoutStore,
|
||||
} from "@/widgets/types";
|
||||
|
||||
type RegisteredHook = {
|
||||
pluginId: string;
|
||||
priority: number;
|
||||
handler: HookHandler<HookName>;
|
||||
};
|
||||
|
||||
export class HookRegistry {
|
||||
private readonly hooks = new Map<HookName, RegisteredHook[]>();
|
||||
|
||||
add<T extends HookName>(
|
||||
name: T,
|
||||
pluginId: string,
|
||||
priority: number,
|
||||
handler: HookHandler<T>,
|
||||
): void {
|
||||
const list = this.hooks.get(name) ?? [];
|
||||
list.push({
|
||||
pluginId,
|
||||
priority,
|
||||
handler: handler as HookHandler<HookName>,
|
||||
});
|
||||
list.sort((a, b) => b.priority - a.priority);
|
||||
this.hooks.set(name, list);
|
||||
}
|
||||
|
||||
remove<T extends HookName>(
|
||||
name: T,
|
||||
handler: HookHandler<T>,
|
||||
): void {
|
||||
const list = this.hooks.get(name);
|
||||
if (!list) return;
|
||||
const next = list.filter((h) => h.handler !== handler);
|
||||
if (next.length) this.hooks.set(name, next);
|
||||
else this.hooks.delete(name);
|
||||
}
|
||||
|
||||
getHandlers(name: HookName): RegisteredHook[] {
|
||||
return [...(this.hooks.get(name) ?? [])];
|
||||
}
|
||||
|
||||
removeHooksForPlugin(pluginId: string): void {
|
||||
for (const [name, list] of this.hooks.entries()) {
|
||||
const next = list.filter((h) => h.pluginId !== pluginId);
|
||||
if (next.length !== list.length) {
|
||||
if (next.length) this.hooks.set(name, next);
|
||||
else this.hooks.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Run pipeline for hooks that transform data (best-effort typing). */
|
||||
runSync<T extends HookName>(
|
||||
name: T,
|
||||
initial: unknown,
|
||||
ctx: HookContext,
|
||||
): unknown {
|
||||
let acc = initial;
|
||||
for (const { handler } of this.getHandlers(name)) {
|
||||
const fn = handler as (data: unknown, c: HookContext) => unknown;
|
||||
acc = fn(acc, ctx);
|
||||
if (acc === false) break;
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stub store until real Zustand layout store is wired */
|
||||
export function createStubLayoutStore(): LayoutStore {
|
||||
return { __stub: true };
|
||||
}
|
||||
64
packages/ui-next/src/widgets/plugins/PluginManager.ts
Normal file
64
packages/ui-next/src/widgets/plugins/PluginManager.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { createPluginAPI } from "@/widgets/plugins/createPluginAPI";
|
||||
import { HookRegistry } from "@/widgets/plugins/HookRegistry";
|
||||
import { SlotRegistry } from "@/widgets/plugins/SlotRegistry";
|
||||
import { WidgetRegistry } from "@/widgets/registry/WidgetRegistry";
|
||||
import type { WidgetPlugin } from "@/widgets/types";
|
||||
|
||||
export class PluginManager {
|
||||
private readonly plugins = new Map<string, WidgetPlugin>();
|
||||
|
||||
constructor(
|
||||
private readonly widgetRegistry: WidgetRegistry,
|
||||
private readonly hookRegistry: HookRegistry,
|
||||
private readonly slotRegistry: SlotRegistry,
|
||||
private readonly layoutStore: Record<string, unknown>,
|
||||
) {}
|
||||
|
||||
async register(plugin: WidgetPlugin): Promise<void> {
|
||||
const missing = plugin.requires?.filter((dep) => !this.plugins.has(dep));
|
||||
if (missing?.length) {
|
||||
throw new Error(`Plugin ${plugin.id} missing deps: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const api = createPluginAPI({
|
||||
widgetRegistry: this.widgetRegistry,
|
||||
hookRegistry: this.hookRegistry,
|
||||
slotRegistry: this.slotRegistry,
|
||||
layoutStore: this.layoutStore,
|
||||
pluginId: plugin.id,
|
||||
priority: plugin.priority ?? 0,
|
||||
});
|
||||
|
||||
try {
|
||||
await plugin.setup(api);
|
||||
} catch (e) {
|
||||
console.error(`[widgets] Plugin setup failed: ${plugin.id}`, e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.plugins.set(plugin.id, plugin);
|
||||
}
|
||||
|
||||
async unregister(pluginId: string): Promise<void> {
|
||||
const plugin = this.plugins.get(pluginId);
|
||||
if (!plugin) return;
|
||||
try {
|
||||
plugin.teardown?.();
|
||||
} catch (e) {
|
||||
console.error(`[widgets] Plugin teardown failed: ${pluginId}`, e);
|
||||
}
|
||||
this.hookRegistry.removeHooksForPlugin(pluginId);
|
||||
this.slotRegistry.clearPlugin(pluginId);
|
||||
this.plugins.delete(pluginId);
|
||||
}
|
||||
|
||||
getPlugins(): WidgetPlugin[] {
|
||||
return [...this.plugins.values()].sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
hasPlugin(id: string): boolean {
|
||||
return this.plugins.has(id);
|
||||
}
|
||||
}
|
||||
60
packages/ui-next/src/widgets/plugins/SlotRegistry.ts
Normal file
60
packages/ui-next/src/widgets/plugins/SlotRegistry.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import type { SlotId, SlotProps } from "@/widgets/types";
|
||||
|
||||
export type SlotEntry = {
|
||||
/** Stable within session — supports multiple injections per plugin */
|
||||
entryId: string;
|
||||
pluginId: string;
|
||||
component: ComponentType<SlotProps>;
|
||||
};
|
||||
|
||||
const EMPTY: readonly SlotEntry[] = [];
|
||||
|
||||
/**
|
||||
* Holds injected slot components; `subscribe` lets React re-render ExtensionSlot.
|
||||
*/
|
||||
export class SlotRegistry {
|
||||
private version = 0;
|
||||
private readonly slots = new Map<SlotId, SlotEntry[]>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
inject(slotId: SlotId, pluginId: string, component: ComponentType<SlotProps>): void {
|
||||
const list = this.slots.get(slotId) ?? [];
|
||||
const entryId = `${pluginId}:${list.length}:${Date.now()}`;
|
||||
list.push({ entryId, pluginId, component });
|
||||
this.slots.set(slotId, list);
|
||||
this.bump();
|
||||
}
|
||||
|
||||
clearPlugin(pluginId: string): void {
|
||||
let changed = false;
|
||||
for (const [id, list] of this.slots) {
|
||||
const next = list.filter((e) => e.pluginId !== pluginId);
|
||||
if (next.length !== list.length) {
|
||||
changed = true;
|
||||
if (next.length) this.slots.set(id, next);
|
||||
else this.slots.delete(id);
|
||||
}
|
||||
}
|
||||
if (changed) this.bump();
|
||||
}
|
||||
|
||||
get(slotId: SlotId): readonly SlotEntry[] {
|
||||
return this.slots.get(slotId) ?? EMPTY;
|
||||
}
|
||||
|
||||
getVersion(): number {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
subscribe = (onStoreChange: () => void): (() => void) => {
|
||||
this.listeners.add(onStoreChange);
|
||||
return () => this.listeners.delete(onStoreChange);
|
||||
};
|
||||
|
||||
private bump(): void {
|
||||
this.version += 1;
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
88
packages/ui-next/src/widgets/plugins/createPluginAPI.ts
Normal file
88
packages/ui-next/src/widgets/plugins/createPluginAPI.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import type {
|
||||
BaseWidgetProps,
|
||||
ConfigField,
|
||||
HookHandler,
|
||||
HookName,
|
||||
LayoutStore,
|
||||
PluginAPI,
|
||||
SlotId,
|
||||
SlotProps,
|
||||
} from "@/widgets/types";
|
||||
|
||||
import { HookRegistry } from "@/widgets/plugins/HookRegistry";
|
||||
import { SlotRegistry } from "@/widgets/plugins/SlotRegistry";
|
||||
import { WidgetRegistry } from "@/widgets/registry/WidgetRegistry";
|
||||
|
||||
type CreatePluginAPIParams = {
|
||||
widgetRegistry: WidgetRegistry;
|
||||
hookRegistry: HookRegistry;
|
||||
slotRegistry: SlotRegistry;
|
||||
layoutStore: Record<string, unknown>;
|
||||
pluginId: string;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
export function createPluginAPI({
|
||||
widgetRegistry,
|
||||
hookRegistry,
|
||||
slotRegistry,
|
||||
layoutStore,
|
||||
pluginId,
|
||||
priority,
|
||||
}: CreatePluginAPIParams): PluginAPI {
|
||||
return {
|
||||
registerWidget: (definition) => {
|
||||
widgetRegistry.register(definition);
|
||||
},
|
||||
|
||||
unregisterWidget: (widgetId) => {
|
||||
widgetRegistry.unregister(widgetId);
|
||||
},
|
||||
|
||||
modifyWidget: (widgetId, patch) => {
|
||||
widgetRegistry.modify(widgetId, patch);
|
||||
},
|
||||
|
||||
wrapWidget: (widgetId, wrapper) => {
|
||||
const def = widgetRegistry.get(widgetId);
|
||||
if (!def) return;
|
||||
const Inner = def.component;
|
||||
widgetRegistry.modify(widgetId, {
|
||||
component: wrapper(Inner) as typeof Inner,
|
||||
});
|
||||
},
|
||||
|
||||
extendConfig: (widgetId, fields) => {
|
||||
const def = widgetRegistry.get(widgetId);
|
||||
if (!def) return;
|
||||
const prev = def.metadata.configSchema ?? {};
|
||||
widgetRegistry.modify(widgetId, {
|
||||
metadata: {
|
||||
...def.metadata,
|
||||
configSchema: { ...prev, ...fields } as typeof def.metadata.configSchema,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
addHook: <T extends HookName>(name: T, handler: HookHandler<T>) => {
|
||||
hookRegistry.add(name, pluginId, priority, handler);
|
||||
},
|
||||
|
||||
removeHook: <T extends HookName>(name: T, handler: HookHandler<T>) => {
|
||||
hookRegistry.remove(name, handler);
|
||||
},
|
||||
|
||||
injectSlot: (slotId: SlotId, component: ComponentType<SlotProps>) => {
|
||||
slotRegistry.inject(slotId, pluginId, component);
|
||||
},
|
||||
|
||||
getStore: () => layoutStore as Readonly<LayoutStore>,
|
||||
|
||||
subscribe: <T>(selector: (state: LayoutStore) => T, callback: (value: T) => void) => {
|
||||
callback(selector(layoutStore as LayoutStore));
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
35
packages/ui-next/src/widgets/registry/WidgetRegistry.ts
Normal file
35
packages/ui-next/src/widgets/registry/WidgetRegistry.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { BaseWidgetProps, WidgetDefinition } from "@/widgets/types";
|
||||
|
||||
export class WidgetRegistry {
|
||||
private readonly defs = new Map<string, WidgetDefinition<BaseWidgetProps>>();
|
||||
|
||||
register<P extends BaseWidgetProps>(definition: WidgetDefinition<P>): void {
|
||||
const id = definition.metadata.id;
|
||||
this.defs.set(id, definition as WidgetDefinition<BaseWidgetProps>);
|
||||
}
|
||||
|
||||
unregister(widgetId: string): void {
|
||||
this.defs.delete(widgetId);
|
||||
}
|
||||
|
||||
get(widgetId: string): WidgetDefinition<BaseWidgetProps> | undefined {
|
||||
return this.defs.get(widgetId);
|
||||
}
|
||||
|
||||
getAll(): WidgetDefinition<BaseWidgetProps>[] {
|
||||
return [...this.defs.values()];
|
||||
}
|
||||
|
||||
/** Non-destructive metadata merge + optional component replace */
|
||||
modify(widgetId: string, patch: Partial<WidgetDefinition>): void {
|
||||
const cur = this.defs.get(widgetId);
|
||||
if (!cur) return;
|
||||
this.defs.set(widgetId, {
|
||||
...cur,
|
||||
...patch,
|
||||
metadata: patch.metadata
|
||||
? { ...cur.metadata, ...patch.metadata }
|
||||
: cur.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
32
packages/ui-next/src/widgets/slots/ExtensionSlot.tsx
Normal file
32
packages/ui-next/src/widgets/slots/ExtensionSlot.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Fragment, useSyncExternalStore } from "react";
|
||||
|
||||
import { slotRegistry } from "@/widgets/system";
|
||||
import type { SlotId, SlotProps } from "@/widgets/types";
|
||||
|
||||
type ExtensionSlotProps = SlotProps & {
|
||||
slotId: SlotId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders all components injected into a named slot (`PluginAPI.injectSlot`).
|
||||
* See `packages/ui/docs/widgets-api.md` §13 (Slot Injection).
|
||||
*/
|
||||
export function ExtensionSlot(props: ExtensionSlotProps) {
|
||||
const { slotId, ...slotProps } = props;
|
||||
|
||||
const entries = useSyncExternalStore(
|
||||
slotRegistry.subscribe,
|
||||
() => slotRegistry.get(slotId),
|
||||
() => slotRegistry.get(slotId),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.map(({ component: Component, entryId }) => (
|
||||
<Fragment key={entryId}>
|
||||
<Component {...slotProps} />
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
packages/ui-next/src/widgets/system.ts
Normal file
20
packages/ui-next/src/widgets/system.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Singleton widget system for the POC. For tests, instantiate `PluginManager` + registries manually.
|
||||
*/
|
||||
import { PluginManager } from "@/widgets/plugins/PluginManager";
|
||||
import { HookRegistry } from "@/widgets/plugins/HookRegistry";
|
||||
import { SlotRegistry } from "@/widgets/plugins/SlotRegistry";
|
||||
import { WidgetRegistry } from "@/widgets/registry/WidgetRegistry";
|
||||
|
||||
export const layoutStore: Record<string, unknown> = {};
|
||||
|
||||
export const widgetRegistry = new WidgetRegistry();
|
||||
export const hookRegistry = new HookRegistry();
|
||||
export const slotRegistry = new SlotRegistry();
|
||||
|
||||
export const pluginManager = new PluginManager(
|
||||
widgetRegistry,
|
||||
hookRegistry,
|
||||
slotRegistry,
|
||||
layoutStore,
|
||||
);
|
||||
194
packages/ui-next/src/widgets/types.ts
Normal file
194
packages/ui-next/src/widgets/types.ts
Normal file
@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Types aligned with `packages/ui/docs/widgets-api.md` (§1, §2, §13).
|
||||
* Intentionally minimal — extend as the host app gains real layout stores.
|
||||
*/
|
||||
import type { ComponentType, ReactElement } from "react";
|
||||
|
||||
// ─── Widget props (§2) ─────────────────────────────────────────────────────
|
||||
|
||||
export interface BaseWidgetProps {
|
||||
widgetInstanceId: string;
|
||||
widgetDefId: string;
|
||||
isEditMode: boolean;
|
||||
enabled?: boolean;
|
||||
onPropsChange: (partial: Record<string, unknown>) => Promise<void>;
|
||||
selectedWidgetId?: string | null;
|
||||
onSelectWidget?: (widgetId: string, pageId?: string) => void;
|
||||
editingWidgetId?: string | null;
|
||||
onEditWidget?: (widgetId: string | null) => void;
|
||||
selectedContainerId?: string | null;
|
||||
onSelectContainer?: (containerId: string, pageId?: string) => void;
|
||||
contextVariables?: Record<string, unknown>;
|
||||
pageContext?: Record<string, unknown>;
|
||||
customClassName?: string;
|
||||
}
|
||||
|
||||
export type WidgetCategory =
|
||||
| "control"
|
||||
| "display"
|
||||
| "layout"
|
||||
| "chart"
|
||||
| "system"
|
||||
| "custom";
|
||||
|
||||
export type WidgetCapability =
|
||||
| "nested-layout"
|
||||
| "translatable"
|
||||
| "data-bound"
|
||||
| "interactive"
|
||||
| "exportable";
|
||||
|
||||
/** Property editor field — full union lives in widgets-api §3 */
|
||||
export interface ConfigField {
|
||||
type: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
group?: string;
|
||||
order?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ConfigSchema<P extends Record<string, unknown>> = Partial<
|
||||
Record<keyof P | string, ConfigField>
|
||||
>;
|
||||
|
||||
export interface WidgetMetadata<P extends BaseWidgetProps = BaseWidgetProps> {
|
||||
id: string;
|
||||
name: string;
|
||||
category: WidgetCategory;
|
||||
description: string;
|
||||
icon?: ComponentType;
|
||||
thumbnail?: string;
|
||||
defaultProps?: Partial<P>;
|
||||
configSchema?: ConfigSchema<P & Record<string, unknown>>;
|
||||
minSize?: { width: number; height: number };
|
||||
resizable?: boolean;
|
||||
tags?: string[];
|
||||
capabilities?: WidgetCapability[];
|
||||
maxInstances?: number;
|
||||
}
|
||||
|
||||
export interface WidgetDefinition<P extends BaseWidgetProps = BaseWidgetProps> {
|
||||
component: ComponentType<P>;
|
||||
metadata: WidgetMetadata<P>;
|
||||
editComponent?: React.LazyExoticComponent<ComponentType<P>>;
|
||||
previewComponent?: ComponentType<P>;
|
||||
validate?: (props: P) => Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
widgetId: string;
|
||||
props?: Record<string, unknown>;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
// ─── Plugins & hooks (§13) ───────────────────────────────────────────────────
|
||||
|
||||
export type LayoutStore = Record<string, unknown>;
|
||||
|
||||
export interface HookContext {
|
||||
pluginId: string;
|
||||
pageId: string;
|
||||
isEditMode: boolean;
|
||||
store: Readonly<LayoutStore>;
|
||||
}
|
||||
|
||||
export type HookName =
|
||||
| "widget:beforeRender"
|
||||
| "widget:afterRender"
|
||||
| "widget:beforeSave"
|
||||
| "widget:afterSave"
|
||||
| "widget:beforeAdd"
|
||||
| "widget:afterAdd"
|
||||
| "widget:beforeRemove"
|
||||
| "widget:afterRemove"
|
||||
| "container:beforeRender"
|
||||
| "container:afterRender"
|
||||
| "container:beforeSave"
|
||||
| "layout:beforeHydrate"
|
||||
| "layout:afterHydrate"
|
||||
| "layout:beforeSave"
|
||||
| "layout:afterSave"
|
||||
| "editor:beforeDrop"
|
||||
| "editor:widgetPalette"
|
||||
| "editor:contextMenu";
|
||||
|
||||
/** Slot identifiers from widgets-api §13 — extend as product adds chrome */
|
||||
export type SlotId =
|
||||
| "editor:toolbar:start"
|
||||
| "editor:toolbar:end"
|
||||
| "editor:sidebar:top"
|
||||
| "editor:sidebar:bottom"
|
||||
| "editor:panel:right:top"
|
||||
| "editor:panel:right:bottom"
|
||||
| "widget:toolbar"
|
||||
| "container:toolbar"
|
||||
| "container:empty"
|
||||
| "page:header"
|
||||
| "page:footer";
|
||||
|
||||
export interface SlotProps {
|
||||
pageId: string;
|
||||
isEditMode: boolean;
|
||||
selectedWidgetId?: string | null;
|
||||
selectedContainerId?: string | null;
|
||||
}
|
||||
|
||||
export type WidgetWrapper = <P extends BaseWidgetProps>(
|
||||
Component: ComponentType<P>,
|
||||
) => ComponentType<P>;
|
||||
|
||||
export interface PluginAPI {
|
||||
registerWidget: <P extends BaseWidgetProps>(definition: WidgetDefinition<P>) => void;
|
||||
unregisterWidget: (widgetId: string) => void;
|
||||
modifyWidget: (widgetId: string, patch: Partial<WidgetDefinition>) => void;
|
||||
wrapWidget: (widgetId: string, wrapper: WidgetWrapper) => void;
|
||||
extendConfig: (
|
||||
widgetId: string,
|
||||
fields: Record<string, ConfigField>,
|
||||
) => void;
|
||||
addHook: <T extends HookName>(name: T, handler: HookHandler<T>) => void;
|
||||
removeHook: <T extends HookName>(name: T, handler: HookHandler<T>) => void;
|
||||
injectSlot: (
|
||||
slotId: SlotId,
|
||||
component: ComponentType<SlotProps>,
|
||||
) => void;
|
||||
getStore: () => Readonly<LayoutStore>;
|
||||
subscribe: <T>(
|
||||
selector: (state: LayoutStore) => T,
|
||||
callback: (value: T) => void,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
export interface WidgetPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
priority?: number;
|
||||
requires?: string[];
|
||||
setup: (api: PluginAPI) => void | Promise<void>;
|
||||
teardown?: () => void;
|
||||
}
|
||||
|
||||
/** Narrow hook handler typing — expand per hook as needed */
|
||||
export type HookHandler<T extends HookName> =
|
||||
T extends "editor:widgetPalette"
|
||||
? (
|
||||
widgets: WidgetDefinition[],
|
||||
ctx: HookContext,
|
||||
) => WidgetDefinition[]
|
||||
: T extends "widget:beforeRender"
|
||||
? (
|
||||
props: Record<string, unknown>,
|
||||
widget: WidgetInstance,
|
||||
ctx: HookContext,
|
||||
) => Record<string, unknown> | false
|
||||
: T extends "widget:afterRender"
|
||||
? (
|
||||
element: ReactElement,
|
||||
widget: WidgetInstance,
|
||||
ctx: HookContext,
|
||||
) => ReactElement
|
||||
: (data: unknown, ctx: HookContext) => unknown;
|
||||
@ -0,0 +1,16 @@
|
||||
import type { BaseWidgetProps } from "@/widgets/types";
|
||||
|
||||
export interface TextBlockWidgetProps extends BaseWidgetProps {
|
||||
title?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export function TextBlockWidget(props: TextBlockWidgetProps) {
|
||||
const { title, body } = props;
|
||||
return (
|
||||
<div className="panel widget-text-block">
|
||||
<h3>{title ?? "Text block"}</h3>
|
||||
{body ? <p>{body}</p> : <p className="muted">Empty body</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user