Skip to content

Commit 1c5f07a

Browse files
aryan02420manzt
authored andcommitted
feat: Vue AFM bridge (#854)
1 parent 441ad5a commit 1c5f07a

File tree

8 files changed

+437
-0
lines changed

8 files changed

+437
-0
lines changed

.changeset/true-files-juggle.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@anywidget/vue": minor
3+
---
4+
5+
Initial release
6+
7+
Thanks to @aryan02420, we now support Vue with an official framework bridge—similar to our React and Svelte bindings.
8+
9+
Unlike `@anywidget/react`, the `useModelState` hook returns a Vue `ShallowRef` that you can update directly (e.g. `value++`), aligning with Vue’s reactivity model.
10+
11+
```javascript
12+
// src/index.js
13+
import { createRender } from "@anywidget/vue";
14+
import CounterWidget from "./CounterWidget.vue";
15+
16+
const render = createRender(CounterWidget);
17+
export default { render };
18+
```
19+
20+
```vue
21+
<!-- src/CounterWidget.vue -->
22+
<script setup>
23+
import { useModelState } from "@anywidget/vue";
24+
const value = useModelState("value");
25+
</script>
26+
27+
<template>
28+
<button :onClick="() => value++">count is {{ value }}</button>
29+
</template>
30+
```
31+
32+
See the README for build tool configuration.
33+

packages/vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# @anywidget/vue

packages/vue/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022-2023 Trevor Manz
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.

packages/vue/README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# @anywidget/vue
2+
3+
> Vue utilities for [**anywidget**](https://anywidget.dev)
4+
5+
## Installation
6+
7+
```sh
8+
npm install @anywidget/vue
9+
```
10+
11+
## Usage
12+
13+
```javascript
14+
// src/index.js
15+
import { createRender } from "@anywidget/vue";
16+
import CounterWidget from "./CounterWidget.vue";
17+
18+
const render = createRender(CounterWidget);
19+
20+
export default { render };
21+
```
22+
23+
```vue
24+
<!-- src/CounterWidget.vue -->
25+
<script setup>
26+
import { useModelState } from "@anywidget/vue";
27+
28+
const value = useModelState("value");
29+
</script>
30+
31+
<template>
32+
<button :onClick="() => value++">count is {{value}}</button>
33+
</template>
34+
35+
<style scoped>
36+
</style>
37+
```
38+
39+
## Bundlers
40+
41+
You'll need to compile the above source files into a single ESM entrypoint for
42+
**anywidget** with a bundler.
43+
44+
### Vite
45+
46+
We currently recommend using [Vite](https://vite.dev/) in [library mode](https://vite.dev/guide/build.html#library-mode).
47+
48+
```sh
49+
pnpm add -D @types/node @vitejs/plugin-vue vite
50+
```
51+
52+
```javascript
53+
// vite.config.js
54+
import { dirname, resolve } from "node:path";
55+
import { fileURLToPath } from "node:url";
56+
import { defineConfig } from "vite";
57+
import vue from "@vitejs/plugin-vue";
58+
59+
const __dirname = dirname(fileURLToPath(import.meta.url));
60+
61+
// https://vite.dev/config/
62+
export default defineConfig({
63+
plugins: [
64+
vue(),
65+
],
66+
build: {
67+
lib: {
68+
entry: resolve(__dirname, "js/CounterWidget.ts"),
69+
// the proper extensions will be added
70+
fileName: "counter-widget",
71+
formats: ["es"],
72+
},
73+
// minify: false, // Uncomment to make it easier to debug errors.
74+
},
75+
define: {
76+
// DOCS: https://vite.dev/guide/build.html#css-support
77+
// > In library mode, all import.meta.env.* usage are statically replaced when building for production.
78+
// > However, process.env.* usage are not, so that consumers of your library can dynamically change it.
79+
//
80+
// The consumer of the widget is a webview, which does not have a top level process object.
81+
// So we need to replace it with a static value.
82+
'process.env.NODE_ENV': '"production"',
83+
},
84+
});
85+
```
86+
87+
```sh
88+
vite build
89+
```
90+
91+
You can read more about using Vite with **anywidget** in
92+
[our documentation](https://anywidget.dev/en/bundling/#vite).
93+
94+
## License
95+
96+
MIT

packages/vue/index.js

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {
2+
createApp,
3+
customRef,
4+
defineComponent,
5+
h,
6+
inject,
7+
onMounted,
8+
onUnmounted,
9+
provide,
10+
toValue,
11+
unref,
12+
} from "vue";
13+
14+
/**
15+
* @template {Record<string, any>} T
16+
* @typedef RenderContext
17+
* @property {import("@anywidget/types").AnyModel<T>} model
18+
* @property {import("@anywidget/types").Experimental} experimental
19+
*/
20+
21+
/**
22+
* @type {import("vue").InjectionKey<RenderContext<any>>}
23+
*/
24+
const RENDER_CONTEXT_KEY = Symbol("anywidget.RenderContext");
25+
26+
/**
27+
* @returns {RenderContext<any>}
28+
*/
29+
function useRenderContext() {
30+
let ctx = inject(RENDER_CONTEXT_KEY);
31+
if (!ctx) throw new Error("anywidget.RenderContext is not provided.");
32+
return ctx;
33+
}
34+
35+
/**
36+
* @template {Record<string, any>} T
37+
* @returns {import("@anywidget/types").AnyModel<T>}
38+
*/
39+
export function useModel() {
40+
let ctx = useRenderContext();
41+
return ctx.model;
42+
}
43+
44+
/** @returns {import("@anywidget/types").Experimental} */
45+
export function useExperimental() {
46+
let ctx = useRenderContext();
47+
return ctx.experimental;
48+
}
49+
50+
/**
51+
* A Vue Composable to use model-backed state in a component.
52+
*
53+
* Returns a ShallowRef that synchronizes its value with
54+
* the underlying model provided by an anywidget host.
55+
*
56+
* @example
57+
* ```ts
58+
* import { useModelState } from "@anywidget/vue";
59+
*
60+
* function Counter() {
61+
* const value = useModelState<number>("value");
62+
*
63+
* return (
64+
* <button onClick={() => value++}>
65+
* Count: {value}
66+
* </button>
67+
* );
68+
* }
69+
* ```
70+
*
71+
* @template S
72+
* @param {import("vue").MaybeRef<string>} key - The name of the model field to use
73+
* @returns {import("vue").ShallowRef<S>}
74+
*/
75+
export function useModelState(key) {
76+
const model = useModel();
77+
78+
/**
79+
* @type {VoidFunction}
80+
*/
81+
let trigger;
82+
83+
/**
84+
* @type {import("vue").Ref<S>}
85+
*/
86+
const value = customRef((_track, _trigger) => {
87+
trigger = _trigger;
88+
return {
89+
get() {
90+
_track();
91+
return model.get(unref(key));
92+
},
93+
set(newValue) {
94+
model.set(unref(key), toValue(newValue));
95+
model.save_changes();
96+
},
97+
};
98+
});
99+
100+
const update = () => {
101+
value.value = model.get(unref(key));
102+
trigger();
103+
};
104+
105+
onMounted(() => {
106+
model.on(`change:${key}`, update);
107+
});
108+
109+
onUnmounted(() => {
110+
model.off(`change:${key}`, update);
111+
});
112+
113+
return value;
114+
}
115+
116+
/**
117+
* @type {import("vue").DefineSetupFnComponent<RenderContext<any>>}
118+
*/
119+
const WidgetWrapper = defineComponent(
120+
({ model, experimental }, ctx) => {
121+
provide(RENDER_CONTEXT_KEY, { model, experimental });
122+
return () => ctx.slots?.default?.();
123+
},
124+
{
125+
props: ["model", "experimental"],
126+
name: "WidgetWrapper",
127+
},
128+
);
129+
130+
/**
131+
* @param {import("vue").Component} Widget
132+
* @returns {import("@anywidget/types").Render}
133+
*/
134+
export function createRender(Widget) {
135+
return ({ el, model, experimental }) => {
136+
const app = createApp(h(WidgetWrapper, { model, experimental }, h(Widget)));
137+
app.mount(el);
138+
139+
return () => app.unmount();
140+
};
141+
}

packages/vue/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@anywidget/vue",
3+
"type": "module",
4+
"version": "0.0.0",
5+
"description": "Vue utilities for anywidget",
6+
"main": "index.js",
7+
"types": "dist/index.d.ts",
8+
"files": [
9+
"dist"
10+
],
11+
"exports": {
12+
".": {
13+
"types": "./dist/index.d.ts",
14+
"import": "./index.js"
15+
}
16+
},
17+
"peerDependencies": {
18+
"vue": "^3.5.13"
19+
},
20+
"devDependencies": {
21+
"vue": "^3.5.13"
22+
},
23+
"dependencies": {
24+
"@anywidget/types": "workspace:^"
25+
}
26+
}

packages/vue/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"include": ["*.js"],
4+
"compilerOptions": {
5+
"outDir": "dist",
6+
"emitDeclarationOnly": true
7+
}
8+
}

0 commit comments

Comments
 (0)