Skip to content

Commit

Permalink
feat(createTemplatePromise): new function (#2957)
Browse files Browse the repository at this point in the history
Co-authored-by: Enzo Innocenzi <enzo@innocenzi.dev>
  • Loading branch information
antfu and innocenzi committed Apr 13, 2023
1 parent 2a912d8 commit 131696d
Show file tree
Hide file tree
Showing 17 changed files with 455 additions and 24 deletions.
6 changes: 6 additions & 0 deletions packages/.vitepress/theme/styles/demo.css
Expand Up @@ -5,6 +5,7 @@
position: relative;
margin-bottom: 10px;
border-radius: 8px;
z-index: 50;
transition: background-color 0.5s;
}

Expand Down Expand Up @@ -182,6 +183,11 @@
border-style: dashed;
padding: 1rem;
}

dialog {
z-index: 1000000;
background: var(--vp-c-bg);
}
}


Expand Down
3 changes: 3 additions & 0 deletions packages/.vitepress/theme/styles/vars.css
Expand Up @@ -13,7 +13,10 @@

--vp-c-disabled-bg: rgba(125,125,125,0.2);
/* fix contrast on gray cards: used by --vp-c-text-2 */
--vp-c-text-light-2: rgba(56 56 56 / 70%);
--vp-c-text-dark-2: rgba(56 56 56 / 70%);

--vp-custom-block-tip-bg: transparent;
}

.dark {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/createReusableTemplate/index.md
Expand Up @@ -7,6 +7,7 @@ outline: deep

Define and reuse template inside the component scope.


## Motivation

It's common to have the need to reuse some part of the template. For example:
Expand Down Expand Up @@ -179,7 +180,7 @@ const [DefineTemplate, ReuseTemplate] = createReusableTemplate()
</template>
```

::: info
::: warning
Passing slots does not work in Vue 2.
:::

Expand All @@ -195,4 +196,3 @@ Alternative Approaches:

- [Vue Macros - `namedTemplate`](https://vue-macros.sxzz.moe/features/named-template.html)
- [`unplugin-@vueuse/core`](https://github.com/liulinboyi/unplugin-@vueuse/core)

34 changes: 25 additions & 9 deletions packages/core/createReusableTemplate/index.ts
@@ -1,12 +1,11 @@
import type { DefineComponent, Slot } from 'vue-demi'
import { defineComponent } from 'vue-demi'
import { __onlyVue27Plus, makeDestructurable } from '@vueuse/shared'
import { defineComponent, isVue3, version } from 'vue-demi'
import { makeDestructurable } from '@vueuse/shared'

export type DefineTemplateComponent<
Bindings extends object,
Slots extends Record<string, Slot | undefined>,
Props = {},
> = DefineComponent<Props> & {
> = DefineComponent<{}> & {
new(): { $slots: { default(_: Bindings & { $slots: Slots }): any } }
}

Expand All @@ -17,6 +16,17 @@ export type ReuseTemplateComponent<
new(): { $slots: Slots }
}

export type ReusableTemplatePair<
Bindings extends object,
Slots extends Record<string, Slot | undefined>,
> = [
DefineTemplateComponent<Bindings, Slots>,
ReuseTemplateComponent<Bindings, Slots>,
] & {
define: DefineTemplateComponent<Bindings, Slots>
reuse: ReuseTemplateComponent<Bindings, Slots>
}

/**
* This function creates `define` and `reuse` components in pair,
* It also allow to pass a generic to bind with type.
Expand All @@ -26,8 +36,14 @@ export type ReuseTemplateComponent<
export function createReusableTemplate<
Bindings extends object,
Slots extends Record<string, Slot | undefined> = Record<string, Slot | undefined>,
>(name?: string) {
__onlyVue27Plus()
>(): ReusableTemplatePair<Bindings, Slots> {
// compatibility: Vue 2.7 or above
if (!isVue3 && !version.startsWith('2.7.')) {
if (process.env.NODE_ENV !== 'production')
throw new Error('[VueUse] createReusableTemplate only works in Vue 2.7 or above.')
// @ts-expect-error incompatible
return
}

let render: Slot | undefined

Expand All @@ -44,14 +60,14 @@ export function createReusableTemplate<
setup(_, { attrs, slots }) {
return () => {
if (!render && process.env.NODE_ENV !== 'production')
throw new Error(`[VueUse] Failed to find the definition of template${name ? ` "${name}"` : ''}`)
throw new Error('[VueUse] Failed to find the definition of reusable template')
return render?.({ ...attrs, $slots: slots })
}
},
}) as ReuseTemplateComponent<Bindings, Slots>

return makeDestructurable(
{ define, reuse },
[define, reuse] as const,
)
[define, reuse],
) as any
}
68 changes: 68 additions & 0 deletions packages/core/createTemplatePromise/demo.vue
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { createTemplatePromise } from '.'
type DialogResult = 'ok' | 'cancel'
const TemplatePromise = createTemplatePromise<DialogResult, [string]>({
transition: {
name: 'fade',
appear: true,
},
})
async function open(idx: number) {
console.log(idx, 'Before')
const result = await TemplatePromise.start(`Hello ${idx}`)
console.log(idx, 'After', result)
}
function asyncFn() {
return new Promise<DialogResult>((resolve) => {
setTimeout(() => {
resolve('ok')
}, 1000)
})
}
</script>

<template>
<div class="flex gap-2">
<button @click="open(1)">
Open 1
</button>
<button @click="open(2)">
Open 2
</button>
<button @click="open(1); open(2)">
Open 1 & 2
</button>
</div>
<TemplatePromise v-slot="{ resolve, args, isResolving }">
<div class="fixed inset-0 bg-black/10 flex items-center">
<dialog open class="border-gray/10 shadow rounded ma">
<div>Dialog {{ args[0] }}</div>
<p>Open console to see logs</p>
<div class="flex gap-2 justify-end">
<button class="w-35" @click="resolve('cancel')">
Cancel
</button>
<button class="w-35" :disabled="isResolving" @click="resolve(asyncFn())">
{{ isResolving ? 'Confirming...' : 'OK' }}
</button>
</div>
</dialog>
</div>
</TemplatePromise>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
175 changes: 175 additions & 0 deletions packages/core/createTemplatePromise/index.md
@@ -0,0 +1,175 @@
---
category: Component
outline: deep
---

# createTemplatePromise

Template as Promise. Useful for constructing custom Dialogs, Modals, Toasts, etc.

## Usage

```html
<script setup lang="ts">
import { createTemplatePromise } from '@vueuse/core'
const TemplatePromise = createTemplatePromise<ReturnType>()
async function open() {
const result = await TemplatePromise.start()
// button is clicked, result is 'ok'
}
</script>

<template>
<TemplatePromise v-slot="{ promise, resolve, reject, args }">
<!-- your UI -->
<button @click="resolve('ok')">OK</button>
</TemplatePromise>
</template>
```

::: tip
This function is migrated from [vue-template-promise](https://github.com/antfu/vue-template-promise)
:::

## Features

- **Programmatic** - call your UI as a promise
- **Template** - use Vue template to render, not a new DSL
- **TypeScript** - full type safety via generic type
- **Renderless** - you take full control of the UI
- **Transition** - use support Vue transition

## Usage

`createTemplatePromise` returns a **Vue Component** that you can directly use in your template with `<script setup>`

```ts
import { createTemplatePromise } from '@vueuse/core'

const TemplatePromise = createTemplatePromise()
const MyPromise = createTemplatePromise<boolean>() // with generic type
```

In template, use `v-slot` to access the promise and resolve functions.

```html
<template>
<TemplatePromise v-slot="{ promise, resolve, reject, args }">
<!-- you can have anything -->
<button @click="resolve('ok')">OK</button>
</TemplatePromise>
<MyPromise v-slot="{ promise, resolve, reject, args }">
<!-- another one -->
</MyPromise>
</template>
```

The slot will not be rendered initially (similar to `v-if="false"`), until you call the `start` method from the component.

```ts
const result = await TemplatePromise.start()
```

Once `resolve` or `reject` is called in the template, the promise will be resolved or rejected, returning the value you passed in. Once resolved, the slot will be removed automatically.

### Passing Arguments

You can pass arguments to the `start` with arguments.

```ts
import { createTemplatePromise } from '@vueuse/core'

const TemplatePromise = createTemplatePromise<boolean, [string, number]>()
```

```ts
const result = await TemplatePromise.start('hello', 123) // Pr
```

And in the template slot, you can access the arguments via `args` property.

```html
<template>
<TemplatePromise v-slot="{ args, resolve }">
<div>{{ args[0] }}</div> <!-- hello -->
<div>{{ args[1] }}</div> <!-- 123 -->
<button @click="resolve(true)">OK</button>
</TemplatePromise>
</template>
```

### Transition

You can use transition to animate the slot.

```html
<script setup lang="ts">
const TemplatePromise = createTemplatePromise<ReturnType>({
transition: {
name: 'fade',
appear: true,
},
})
</script>

<template>
<TemplatePromise v-slot="{ resolve }">
<!-- your UI -->
<button @click="resolve('ok')">OK</button>
</TemplatePromise>
</template>

<style scoped>
.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
</style>
```

Learn more about [Vue Transition](https://v3.vuejs.org/guide/transitions-overview.html).

## Motivation

The common approach to call a dialog or a model programmatically would be like this:

```ts
const dialog = useDialog()
const result = await dialog.open({
title: 'Hello',
content: 'World',
})
```

This would work by sending these information to the top-level component and let it render the dialog. However, it limits the flexibility you could express in the UI. For example, you could want the title to be red, or have extra buttons, etc. You would end up with a lot of options like:

```ts
const result = await dialog.open({
title: 'Hello',
titleClass: 'text-red',
content: 'World',
contentClass: 'text-blue text-sm',
buttons: [
{ text: 'OK', class: 'bg-red', onClick: () => {} },
{ text: 'Cancel', class: 'bg-blue', onClick: () => {} },
],
// ...
})
```

Even this is not flexible enough. If you want more, you might end up with manual render function.

```ts
const result = await dialog.open({
title: 'Hello',
contentSlot: () => h(MyComponent, { content }),
})
```

This is like reinventing a new DSL in the script to express the UI template.

So this function allows **expressing the UI in templates instead of scripts**, where it is supposed to be, while still being able to be manipulated programmatically.

0 comments on commit 131696d

Please sign in to comment.