@retronew/call-vue 0.2.0
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/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/index.d.mts +72 -0
- package/dist/index.mjs +170 -0
- package/package.json +60 -0
- package/skills/call-vue/SKILL.md +164 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 retronew
|
|
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,161 @@
|
|
|
1
|
+
# @retronew/call-vue
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@retronew/call-vue)
|
|
4
|
+
[](https://www.npmjs.com/package/@retronew/call-vue)
|
|
5
|
+
[](https://bundlephobia.com/package/@retronew/call-vue)
|
|
6
|
+
[](../../LICENSE)
|
|
7
|
+
|
|
8
|
+
Call & await Vue components like async functions. A Vue 3 port of
|
|
9
|
+
[`react-call`](https://github.com/desko27/react-call)'s core API
|
|
10
|
+
(`createCallable`, `call`/`upsert`/`end`/`update`) built on native Vue
|
|
11
|
+
reactivity — no context providers, no global store to wire up.
|
|
12
|
+
|
|
13
|
+
## Why
|
|
14
|
+
|
|
15
|
+
Confirmation dialogs, prompts, and toasts are usually one-off components you
|
|
16
|
+
have to mount, manage `v-model`/visibility for, and thread a resolve callback
|
|
17
|
+
through. `createCallable` turns the component itself into an awaitable
|
|
18
|
+
function:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const confirmed = await Confirm.call({ message: 'Delete this file?' })
|
|
22
|
+
if (confirmed) deleteFile()
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
No global state, no extra store — `<Confirm />` mounted once *is* the stack.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm add @retronew/call-vue
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
1. Define the component. It receives your own props **plus** an injected
|
|
36
|
+
`call` prop carrying the imperative context for this instance:
|
|
37
|
+
|
|
38
|
+
```vue
|
|
39
|
+
<!-- Confirm.vue -->
|
|
40
|
+
<script setup lang="ts">
|
|
41
|
+
import type { PropsWithCall } from '@retronew/call-vue'
|
|
42
|
+
|
|
43
|
+
type Props = { message: string }
|
|
44
|
+
type Response = boolean
|
|
45
|
+
|
|
46
|
+
defineProps<PropsWithCall<Props, Response, Record<string, never>>>()
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div class="dialog">
|
|
51
|
+
<p>{{ message }}</p>
|
|
52
|
+
<button @click="call.end(true)">Yes</button>
|
|
53
|
+
<button @click="call.end(false)">No</button>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
2. Wrap it with `createCallable` and mount the result once, anywhere near the
|
|
59
|
+
root of your app:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// confirm.ts
|
|
63
|
+
import { createCallable } from '@retronew/call-vue'
|
|
64
|
+
import ConfirmDialog from './Confirm.vue'
|
|
65
|
+
|
|
66
|
+
export const Confirm = createCallable<{ message: string }, boolean>(ConfirmDialog)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```vue
|
|
70
|
+
<!-- App.vue -->
|
|
71
|
+
<script setup lang="ts">
|
|
72
|
+
import { Confirm } from './confirm'
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<Confirm />
|
|
77
|
+
<!-- rest of the app -->
|
|
78
|
+
</template>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
3. Call it like an async function, from anywhere:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { Confirm } from './confirm'
|
|
85
|
+
|
|
86
|
+
async function handleDelete() {
|
|
87
|
+
const confirmed = await Confirm.call({ message: 'Delete this file?' })
|
|
88
|
+
if (confirmed) await deleteFile()
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## API
|
|
93
|
+
|
|
94
|
+
- **`Confirm.call(props)`** → `Promise<Response>` — pushes a new instance onto
|
|
95
|
+
the stack; resolves when that instance's `call.end(response)` runs.
|
|
96
|
+
- **`Confirm.upsert(props)`** → `Promise<Response>` — like `call`, but reuses
|
|
97
|
+
the in-flight instance instead of stacking a new one while one is still
|
|
98
|
+
open. Ideal for a single "status" toast whose props change over time
|
|
99
|
+
(`Confirm.upsert({ text: 'Uploading… 42%' })`).
|
|
100
|
+
- **`Confirm.end(response)`** / **`Confirm.end(promise, response)`** —
|
|
101
|
+
resolve every open call, or just the one identified by the promise `call()`/
|
|
102
|
+
`upsert()` returned.
|
|
103
|
+
- **`Confirm.update(props)`** / **`Confirm.update(promise, props)`** —
|
|
104
|
+
shallow-merge partial props into every open call, or just one, without
|
|
105
|
+
resolving it.
|
|
106
|
+
- **`call.key`**, **`call.ended`**, **`call.index`**, **`call.stackSize`**,
|
|
107
|
+
**`call.root`** — read inside the component: stable identity, whether
|
|
108
|
+
`end()` already ran (so you can key an exit transition off it), this
|
|
109
|
+
instance's position in the stack, how many are open, and whatever props
|
|
110
|
+
were passed to `<Confirm rootProp="…" />`.
|
|
111
|
+
|
|
112
|
+
See the [Claude Code skill](skills/call-vue/SKILL.md) for the stacking model,
|
|
113
|
+
`unmountingDelay` exit-transition pattern, and the single-`<Root>` constraint
|
|
114
|
+
in depth.
|
|
115
|
+
|
|
116
|
+
## Exit transitions
|
|
117
|
+
|
|
118
|
+
Pass a second argument to `createCallable` to keep an ended call mounted
|
|
119
|
+
for N milliseconds — long enough for a `<Transition>` to play — before it's
|
|
120
|
+
actually removed from the stack:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
export const Toast = createCallable<Props, Response>(ToastCard, 200 /* ms */)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Inside `ToastCard`, branch on `call.ended` to trigger the leave state.
|
|
127
|
+
|
|
128
|
+
## Stacking
|
|
129
|
+
|
|
130
|
+
Every `call()` while a previous one is still open stacks on top of it —
|
|
131
|
+
`<Confirm />` renders one component instance per open call, each with its own
|
|
132
|
+
`call.index`/`call.stackSize`. Nothing is queued or hidden automatically;
|
|
133
|
+
that's a decision your component makes (e.g. only rendering the top of the
|
|
134
|
+
stack, or rendering all of them with a depth-based transform).
|
|
135
|
+
|
|
136
|
+
## Constraints
|
|
137
|
+
|
|
138
|
+
- Exactly **one** `<Confirm />` may be mounted at a time. `call()`/`upsert()`
|
|
139
|
+
throw `No <Root> found!` if none is mounted yet, or
|
|
140
|
+
`Multiple instances of <Root> found!` if more than one is.
|
|
141
|
+
- Unmounting `<Confirm />` resets its stack — a fresh mount always starts
|
|
142
|
+
empty.
|
|
143
|
+
|
|
144
|
+
## Claude Code skill
|
|
145
|
+
|
|
146
|
+
This package ships a [Claude Code skill](skills/call-vue/SKILL.md) covering
|
|
147
|
+
the stack/upsert model, exit-transition timing, and the single-`<Root>`
|
|
148
|
+
constraint. Claude Code doesn't auto-discover skills inside `node_modules`
|
|
149
|
+
yet, so after installing, copy or symlink it into your project:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
mkdir -p .claude/skills
|
|
153
|
+
cp -r node_modules/@retronew/call-vue/skills/call-vue .claude/skills/call-vue
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Credits
|
|
157
|
+
|
|
158
|
+
API design ported from [`react-call`](https://github.com/desko27/react-call)
|
|
159
|
+
by [@desko27](https://github.com/desko27), reimplemented from scratch on Vue
|
|
160
|
+
3 reactivity primitives (`shallowRef`, no `useSyncExternalStore` equivalent
|
|
161
|
+
needed).
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Component, DefineComponent } from "vue";
|
|
2
|
+
//#region src/createCallable/types.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Properties every `call.*` context carries, regardless of stack position.
|
|
5
|
+
* Mirrors `CallItemPublicProperties` in react-call's store.
|
|
6
|
+
*/
|
|
7
|
+
interface CallItemPublicProperties<Response> {
|
|
8
|
+
/** Stable identity for this call within the stack; safe to use as a `:key`. */
|
|
9
|
+
key: string;
|
|
10
|
+
/** Resolve this specific call's promise and mark it `ended`. */
|
|
11
|
+
end: (response: Response) => void;
|
|
12
|
+
/**
|
|
13
|
+
* `true` once `end()` has resolved this call. The call stays mounted for
|
|
14
|
+
* `unmountingDelay` ms after this flips, so an exit transition can play.
|
|
15
|
+
*/
|
|
16
|
+
ended: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** The `call()` method returned by `createCallable`. */
|
|
19
|
+
type CallFunction<Props, Response> = (props: Props) => Promise<Response>;
|
|
20
|
+
/** The `upsert()` method returned by `createCallable`. */
|
|
21
|
+
type UpsertFunction<Props, Response> = (props: Props) => Promise<Response>;
|
|
22
|
+
/**
|
|
23
|
+
* The special `call` prop every user component receives, on top of its own
|
|
24
|
+
* `Props`. Carries per-call identity/state plus stack/root context.
|
|
25
|
+
*/
|
|
26
|
+
type CallContext<_Props, Response, RootProps> = CallItemPublicProperties<Response> & {
|
|
27
|
+
/** Props passed to `<Root>` (the mounted callable component), if any. */
|
|
28
|
+
root: RootProps;
|
|
29
|
+
/** This call's position in the current stack (0 = oldest). */
|
|
30
|
+
index: number;
|
|
31
|
+
/** Number of calls currently stacked (including this one). */
|
|
32
|
+
stackSize: number;
|
|
33
|
+
};
|
|
34
|
+
/** User props merged with the injected `call` context. */
|
|
35
|
+
type PropsWithCall<Props, Response, RootProps> = Props & {
|
|
36
|
+
call: CallContext<Props, Response, RootProps>;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The shape `createCallable` expects: any Vue component (SFC, `defineComponent`,
|
|
40
|
+
* or functional) whose props satisfy `PropsWithCall<Props, Response, RootProps>`.
|
|
41
|
+
*/
|
|
42
|
+
type UserComponent<Props, Response, RootProps> = Component<PropsWithCall<Props, Response, RootProps>>;
|
|
43
|
+
/**
|
|
44
|
+
* What `createCallable` returns.
|
|
45
|
+
*
|
|
46
|
+
* The callable is the Root component itself — mount it with `<Confirm />`
|
|
47
|
+
* (or `<Confirm.Root />`) and use the imperative methods (`call`, `upsert`,
|
|
48
|
+
* `end`, `update`) as properties on the very same object.
|
|
49
|
+
*/
|
|
50
|
+
type Callable<Props, Response, RootProps> = DefineComponent<RootProps> & {
|
|
51
|
+
/** Alias for the callable itself — `Confirm.Root === Confirm`. */
|
|
52
|
+
Root: DefineComponent<RootProps>;
|
|
53
|
+
call: CallFunction<Props, Response>;
|
|
54
|
+
upsert: UpsertFunction<Props, Response>;
|
|
55
|
+
end: ((promise: Promise<Response>, response: Response) => void) & ((response: Response) => void);
|
|
56
|
+
update: ((promise: Promise<Response>, props: Partial<Props>) => void) & ((props: Partial<Props>) => void);
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/createCallable/index.d.ts
|
|
60
|
+
/**
|
|
61
|
+
* Turns a Vue component into a "callable": mount it once as `<Confirm />`
|
|
62
|
+
* and then `await Confirm.call(props)` from anywhere to push an instance
|
|
63
|
+
* onto its stack and get back a promise that resolves when the instance
|
|
64
|
+
* calls `call.end(response)`.
|
|
65
|
+
*
|
|
66
|
+
* This is a Vue-native port of react-call's `createCallable` — see
|
|
67
|
+
* `skills/call-vue/SKILL.md` for the full mental model (stack, upsert,
|
|
68
|
+
* mutation flow).
|
|
69
|
+
*/
|
|
70
|
+
declare function createCallable<Props = void, Response = void, RootProps extends Record<string, unknown> = Record<string, never>>(UserComponent: UserComponent<Props, Response, RootProps>, unmountingDelay?: number): Callable<Props, Response, RootProps>;
|
|
71
|
+
//#endregion
|
|
72
|
+
export { type CallContext, type CallFunction, type Callable, type PropsWithCall, type UpsertFunction, type UserComponent, createCallable };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { defineComponent, h, onUnmounted, shallowRef } from "vue";
|
|
2
|
+
//#region src/createCallable/store.ts
|
|
3
|
+
/**
|
|
4
|
+
* Vue-native replacement for react-call's `useSyncExternalStore`-backed
|
|
5
|
+
* store: `shallowRef` already gives us a single reactive snapshot per
|
|
6
|
+
* store, so there's no separate subscribe/getSnapshot machinery to hand-roll.
|
|
7
|
+
*
|
|
8
|
+
* `rootCount` plays the role react-call's `listeners.size` plays — it's how
|
|
9
|
+
* `assertSingleRoot` in `createCallable` detects a missing or duplicated
|
|
10
|
+
* `<Root>`. It's tracked here (rather than as its own `ref`) because it must
|
|
11
|
+
* never trigger a re-render on its own; only stack changes should.
|
|
12
|
+
*/
|
|
13
|
+
function createStackStore() {
|
|
14
|
+
let nextKey = 0;
|
|
15
|
+
let upsertPromise = null;
|
|
16
|
+
let rootCount = 0;
|
|
17
|
+
const stack = shallowRef([]);
|
|
18
|
+
return {
|
|
19
|
+
stack,
|
|
20
|
+
add: (call) => {
|
|
21
|
+
stack.value = [...stack.value, {
|
|
22
|
+
...call,
|
|
23
|
+
key: String(nextKey++)
|
|
24
|
+
}];
|
|
25
|
+
},
|
|
26
|
+
set: (promise, updateFn) => {
|
|
27
|
+
stack.value = stack.value.map((call) => promise && call.promise !== promise ? call : updateFn(call));
|
|
28
|
+
},
|
|
29
|
+
remove: (promises) => {
|
|
30
|
+
stack.value = stack.value.filter((c) => !promises.has(c.promise));
|
|
31
|
+
},
|
|
32
|
+
/** Call once from a mounted `<Root>`; call the returned function on unmount. */
|
|
33
|
+
mountRoot: () => {
|
|
34
|
+
rootCount++;
|
|
35
|
+
return () => {
|
|
36
|
+
rootCount--;
|
|
37
|
+
if (rootCount === 0) {
|
|
38
|
+
nextKey = 0;
|
|
39
|
+
stack.value = [];
|
|
40
|
+
upsertPromise = null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
getRootCount: () => rootCount,
|
|
45
|
+
getUpsertPromise: () => upsertPromise,
|
|
46
|
+
setUpsertPromise: (p) => {
|
|
47
|
+
upsertPromise = p;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/createCallable/index.ts
|
|
53
|
+
/**
|
|
54
|
+
* Turns a Vue component into a "callable": mount it once as `<Confirm />`
|
|
55
|
+
* and then `await Confirm.call(props)` from anywhere to push an instance
|
|
56
|
+
* onto its stack and get back a promise that resolves when the instance
|
|
57
|
+
* calls `call.end(response)`.
|
|
58
|
+
*
|
|
59
|
+
* This is a Vue-native port of react-call's `createCallable` — see
|
|
60
|
+
* `skills/call-vue/SKILL.md` for the full mental model (stack, upsert,
|
|
61
|
+
* mutation flow).
|
|
62
|
+
*/
|
|
63
|
+
function createCallable(UserComponent, unmountingDelay = 0) {
|
|
64
|
+
const store = createStackStore();
|
|
65
|
+
const createEnd = (promise) => (response) => {
|
|
66
|
+
const ending = /* @__PURE__ */ new Set();
|
|
67
|
+
store.set(promise, (call) => {
|
|
68
|
+
call.resolve(response);
|
|
69
|
+
ending.add(call.promise);
|
|
70
|
+
return {
|
|
71
|
+
...call,
|
|
72
|
+
ended: true
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
globalThis.setTimeout(() => store.remove(ending), unmountingDelay);
|
|
76
|
+
};
|
|
77
|
+
const assertSingleRoot = () => {
|
|
78
|
+
const count = store.getRootCount();
|
|
79
|
+
if (!count) throw new Error("No <Root> found!");
|
|
80
|
+
if (count > 1) throw new Error("Multiple instances of <Root> found!");
|
|
81
|
+
};
|
|
82
|
+
const call = (props) => {
|
|
83
|
+
assertSingleRoot();
|
|
84
|
+
let resolve;
|
|
85
|
+
const promise = new Promise((res) => {
|
|
86
|
+
resolve = res;
|
|
87
|
+
});
|
|
88
|
+
store.add({
|
|
89
|
+
props,
|
|
90
|
+
end: createEnd(promise),
|
|
91
|
+
ended: false,
|
|
92
|
+
promise,
|
|
93
|
+
resolve
|
|
94
|
+
});
|
|
95
|
+
return promise;
|
|
96
|
+
};
|
|
97
|
+
const upsert = (props) => {
|
|
98
|
+
assertSingleRoot();
|
|
99
|
+
const existing = store.getUpsertPromise();
|
|
100
|
+
if (existing) {
|
|
101
|
+
store.set(existing, (c) => ({
|
|
102
|
+
...c,
|
|
103
|
+
props
|
|
104
|
+
}));
|
|
105
|
+
return existing;
|
|
106
|
+
}
|
|
107
|
+
let resolve;
|
|
108
|
+
const promise = new Promise((res) => {
|
|
109
|
+
resolve = res;
|
|
110
|
+
});
|
|
111
|
+
store.setUpsertPromise(promise);
|
|
112
|
+
store.add({
|
|
113
|
+
props,
|
|
114
|
+
end: (response) => {
|
|
115
|
+
store.setUpsertPromise(null);
|
|
116
|
+
createEnd(promise)(response);
|
|
117
|
+
},
|
|
118
|
+
ended: false,
|
|
119
|
+
promise,
|
|
120
|
+
resolve
|
|
121
|
+
});
|
|
122
|
+
return promise;
|
|
123
|
+
};
|
|
124
|
+
const end = (...args) => {
|
|
125
|
+
const targeted = args.length === 2;
|
|
126
|
+
const promise = targeted ? args[0] : null;
|
|
127
|
+
const response = targeted ? args[1] : args[0];
|
|
128
|
+
if (!targeted || promise === store.getUpsertPromise()) store.setUpsertPromise(null);
|
|
129
|
+
return createEnd(promise)(response);
|
|
130
|
+
};
|
|
131
|
+
const update = (...args) => {
|
|
132
|
+
const targeted = args.length === 2;
|
|
133
|
+
store.set(targeted ? args[0] : null, (c) => ({
|
|
134
|
+
...c,
|
|
135
|
+
props: {
|
|
136
|
+
...c.props,
|
|
137
|
+
...targeted ? args[1] : args[0]
|
|
138
|
+
}
|
|
139
|
+
}));
|
|
140
|
+
};
|
|
141
|
+
const Root = defineComponent({
|
|
142
|
+
name: "CallableRoot",
|
|
143
|
+
inheritAttrs: false,
|
|
144
|
+
setup(_, { attrs }) {
|
|
145
|
+
const unmountRoot = store.mountRoot();
|
|
146
|
+
onUnmounted(unmountRoot);
|
|
147
|
+
return () => store.stack.value.map((item, index, stack) => h(UserComponent, {
|
|
148
|
+
...item.props,
|
|
149
|
+
key: item.key,
|
|
150
|
+
call: {
|
|
151
|
+
key: item.key,
|
|
152
|
+
end: item.end,
|
|
153
|
+
ended: item.ended,
|
|
154
|
+
root: attrs,
|
|
155
|
+
index,
|
|
156
|
+
stackSize: stack.length
|
|
157
|
+
}
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
return Object.assign(Root, {
|
|
162
|
+
Root,
|
|
163
|
+
call,
|
|
164
|
+
upsert,
|
|
165
|
+
end,
|
|
166
|
+
update
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
export { createCallable };
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@retronew/call-vue",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Call & await Vue components like async functions — a Vue 3 port of react-call.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"async",
|
|
7
|
+
"composable",
|
|
8
|
+
"confirm",
|
|
9
|
+
"dialog",
|
|
10
|
+
"modal",
|
|
11
|
+
"vue",
|
|
12
|
+
"vue3"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/retronew/ui-kit#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/retronew/ui-kit/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/retronew/ui-kit.git",
|
|
22
|
+
"directory": "packages/call-vue"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"skills"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
33
|
+
"default": "./dist/index.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^26.2.0",
|
|
42
|
+
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
43
|
+
"@vue/test-utils": "^2.4.11",
|
|
44
|
+
"jsdom": "^30.0.1",
|
|
45
|
+
"typescript": "^7.0.2",
|
|
46
|
+
"vite-plus": "^0.2.9",
|
|
47
|
+
"vitest": "4.1.10",
|
|
48
|
+
"vue": "^3.5.41"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"vue": "^3.5.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "vp pack",
|
|
55
|
+
"dev": "vp pack --watch",
|
|
56
|
+
"test": "vp test",
|
|
57
|
+
"test:coverage": "vp test --coverage",
|
|
58
|
+
"check": "vp check"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: call-vue
|
|
3
|
+
description: Reach for @retronew/call-vue (createCallable) when building UI that resolves a value back to its caller — confirmations, dialogs, form modals, toasts, notifications, context menus, pickers. Use when a Vue task involves any such "await the UI" interaction, when code imports createCallable from @retronew/call-vue, or when the user mentions call-vue, react-call, or Callables in a Vue project. If call-vue isn't a dependency yet but the problem fits, propose adding it. Covers Declare→Root→Call, call vs upsert, exit transitions, and the single-Root rule.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @retronew/call-vue
|
|
7
|
+
|
|
8
|
+
`createCallable()` turns a Vue component into something you can `await`: you
|
|
9
|
+
call it imperatively from anywhere and it resolves with a value. This is a
|
|
10
|
+
Vue-native port of [`react-call`](https://github.com/desko27/react-call)'s
|
|
11
|
+
core API — same mental model, built on Vue's own reactivity (`shallowRef`)
|
|
12
|
+
instead of React's `useSyncExternalStore`.
|
|
13
|
+
|
|
14
|
+
## When to reach for it (and when not)
|
|
15
|
+
|
|
16
|
+
**Reach for it** when a piece of UI conceptually *returns a value to its
|
|
17
|
+
caller* and you want to `await` that value from async code: confirmations,
|
|
18
|
+
dialogs, form modals, toasts/notifications, context menus, pickers,
|
|
19
|
+
multi-step wizards.
|
|
20
|
+
|
|
21
|
+
**Propose it** if `@retronew/call-vue` isn't a dependency yet but the task
|
|
22
|
+
fits — then `pnpm add @retronew/call-vue`.
|
|
23
|
+
|
|
24
|
+
**Don't push it** when another solution is already in place and working —
|
|
25
|
+
mention it as an option, don't refactor unprompted. Skip it for purely
|
|
26
|
+
presentational components that return nothing, and for full-page flows better
|
|
27
|
+
served by routing.
|
|
28
|
+
|
|
29
|
+
## Vocabulary (use these exact terms)
|
|
30
|
+
|
|
31
|
+
- **Callable** — the value `createCallable()` returns. It is *both* a Vue
|
|
32
|
+
component (mount `<Confirm />`) *and* a namespace of methods (`call`,
|
|
33
|
+
`upsert`, `end`, `update`) attached to the same object. Don't call it a
|
|
34
|
+
"modal/dialog/component".
|
|
35
|
+
- **Root** — the mounting form of the Callable: the bare `<Confirm />`
|
|
36
|
+
(`Confirm.Root === Confirm`, kept as an alias). Not a "provider/portal".
|
|
37
|
+
- **Call** — one imperative invocation (`Confirm.call({...})`), resolves to a
|
|
38
|
+
**Response**.
|
|
39
|
+
- **Stack** — the ordered list of active Calls a Root renders (not a
|
|
40
|
+
"queue"). Each renders as one instance of the user component.
|
|
41
|
+
- **CallContext** — the `call` prop your component receives: `{ end, ended,
|
|
42
|
+
key, index, stackSize, root }`. Not a Vue "provide/inject context".
|
|
43
|
+
- **Upsert** — singleton-style Call (`upsert()`).
|
|
44
|
+
|
|
45
|
+
## The model: Declare → Root → Call
|
|
46
|
+
|
|
47
|
+
```vue
|
|
48
|
+
<!-- Confirm.vue -->
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
import type { PropsWithCall } from '@retronew/call-vue'
|
|
51
|
+
|
|
52
|
+
type Props = { message: string }
|
|
53
|
+
type Response = boolean
|
|
54
|
+
|
|
55
|
+
// `call` is the special injected prop (the CallContext)
|
|
56
|
+
defineProps<PropsWithCall<Props, Response, Record<string, never>>>()
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<div role="dialog">
|
|
61
|
+
<p>{{ message }}</p>
|
|
62
|
+
<button @click="call.end(true)">Yes</button>
|
|
63
|
+
<button @click="call.end(false)">No</button>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// confirm.ts — 1. Declare
|
|
70
|
+
import { createCallable } from '@retronew/call-vue'
|
|
71
|
+
import ConfirmDialog from './Confirm.vue'
|
|
72
|
+
|
|
73
|
+
export const Confirm = createCallable<{ message: string }, boolean>(ConfirmDialog)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```vue
|
|
77
|
+
<!-- App.vue — 2. Root: mount once, somewhere always rendered -->
|
|
78
|
+
<template>
|
|
79
|
+
<Confirm />
|
|
80
|
+
</template>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
// 3. Call & await — from anywhere
|
|
85
|
+
const accepted = await Confirm.call({ message: 'Continue?' })
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Generics are `createCallable<Props, Response, RootProps>` (all optional,
|
|
89
|
+
default to `void`/`void`/`Record<string, never>`).
|
|
90
|
+
|
|
91
|
+
Plain functional components work too — `createCallable` accepts anything
|
|
92
|
+
matching Vue's `Component<Props>` type, including a bare
|
|
93
|
+
`(props) => VNode` function, which is often less ceremony than an SFC for a
|
|
94
|
+
tiny callable.
|
|
95
|
+
|
|
96
|
+
## Decision guide
|
|
97
|
+
|
|
98
|
+
- **`call` vs `upsert`** — `call()` opens a new Call every time (they stack).
|
|
99
|
+
`upsert()` is singleton: the first invocation creates the Call, later
|
|
100
|
+
`upsert()` calls update the same one and return the same promise. Use
|
|
101
|
+
`upsert` for toasts, progress, loading — anything that should have at most
|
|
102
|
+
one instance alive.
|
|
103
|
+
- **Root props vs call props** — per-Call data goes in `call()`'s props; data
|
|
104
|
+
shared across every Call (theme, current user) goes in **RootProps**,
|
|
105
|
+
passed as ordinary attrs to `<Confirm userName="…" />` and read back via
|
|
106
|
+
`call.root` inside every mounted instance. `RootProps` arrives through
|
|
107
|
+
Vue's `$attrs` under the hood — `<Root>` declares no props of its own, so
|
|
108
|
+
it forwards whatever you hand it as-is.
|
|
109
|
+
- **End / update from the caller** — `Confirm.end(promise, value)` /
|
|
110
|
+
`Confirm.update(promise, partialProps)` target one Call; omit the promise
|
|
111
|
+
argument to affect every currently active Call instead.
|
|
112
|
+
|
|
113
|
+
## Hard rules (the common failures)
|
|
114
|
+
|
|
115
|
+
- **One Root per Callable.** Mounting `<Confirm />` in two live places throws
|
|
116
|
+
*"Multiple instances of `<Root>` found!"* the next time `call()`/`upsert()`
|
|
117
|
+
runs. Don't mount a Callable per-route or per-feature — mount it once, high
|
|
118
|
+
in the tree (e.g. `App.vue`), same as you'd mount a single toast outlet.
|
|
119
|
+
- **Mount the Root where it stays alive when you call.** If the Root sits in
|
|
120
|
+
a `v-if`-gated or route-unmounted subtree, `call()` from outside it throws
|
|
121
|
+
*"No `<Root>` found!"*. If that's happening, check whether the Root got
|
|
122
|
+
unmounted before the call, not whether `createCallable` was set up wrong.
|
|
123
|
+
- **Unmounting resets the stack.** A Callable's stack is scoped to its
|
|
124
|
+
currently-mounted Root instance — remount it (e.g. via `v-if` toggling
|
|
125
|
+
off/on, or HMR) and any Calls made against the previous mount are gone;
|
|
126
|
+
there's no persistence across mounts.
|
|
127
|
+
- **Exit transitions** need the unmount delay as the 2nd argument to
|
|
128
|
+
`createCallable`, then drive the leave state off `call.ended` (a boolean
|
|
129
|
+
prop, not a Vue `<Transition>` hook by itself — combine the two):
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
export const Toast = createCallable<Props, Response>(ToastCard, 200 /* ms */)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Inside `ToastCard`, branch on `props.call.ended` to add a leaving class or
|
|
136
|
+
swap into an exit-animation state; the instance stays mounted for 200ms
|
|
137
|
+
after `end()` so a CSS transition (or Vue `<Transition>` wrapping the
|
|
138
|
+
Callable's stack render) has time to play before removal.
|
|
139
|
+
|
|
140
|
+
## Anti-patterns
|
|
141
|
+
|
|
142
|
+
- Placing `<Confirm />` per-route or per-feature → multi-Root throw. One
|
|
143
|
+
mount, reused everywhere via `.call()`.
|
|
144
|
+
- Reusing `call()` for singleton UI (toasts, a single progress indicator) →
|
|
145
|
+
duplicate stacked instances. Use `upsert()` instead.
|
|
146
|
+
- Treating the Callable as a plain component to render with data props — the
|
|
147
|
+
props you pass to `<Confirm />` itself are **RootProps** (shared context),
|
|
148
|
+
never the per-instance data; that always goes through `.call(props)`.
|
|
149
|
+
- Reaching into `call.root` for data that changes per-Call — it's constant
|
|
150
|
+
for the Root's whole lifetime (whatever `<Confirm />` was mounted with);
|
|
151
|
+
put per-Call data in the component's own props instead.
|
|
152
|
+
|
|
153
|
+
## Quick reference
|
|
154
|
+
|
|
155
|
+
| Method | Targeted form | Untargeted form |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `call(props)` | — | opens a new Call, returns its `Promise<Response>` |
|
|
158
|
+
| `upsert(props)` | — | opens or updates the pending upsert Call |
|
|
159
|
+
| `end(response)` / `end(promise, response)` | resolves the one Call | resolves every open Call |
|
|
160
|
+
| `update(props)` / `update(promise, props)` | merges into the one Call | merges into every open Call |
|
|
161
|
+
|
|
162
|
+
`call` prop shape inside the component: `{ key, end, ended, root, index,
|
|
163
|
+
stackSize }` — see the package [README](../../README.md) for the full field
|
|
164
|
+
descriptions.
|