Skip to content

Commit 5591d5d

Browse files
committed
feat(Modal): implement programmatic close method and update documentation
1 parent f95abf8 commit 5591d5d

File tree

5 files changed

+133
-72
lines changed

5 files changed

+133
-72
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<template>
2+
<UModal
3+
title="Modal with close method"
4+
description="This is useful when you want to close the modal from within the content without creating a new ref."
5+
>
6+
<UButton label="Open" color="neutral" variant="subtle" />
7+
8+
<template #body="{ close }">
9+
<div class="space-y-4">
10+
<p>You can close this modal using the close method exposed in the slots.</p>
11+
<UButton label="Close Modal" color="primary" @click="close" />
12+
</div>
13+
</template>
14+
15+
<template #footer="{ close }">
16+
<UButton label="Cancel" color="neutral" variant="outline" @click="close" />
17+
<UButton label="Submit" color="neutral" />
18+
</template>
19+
</UModal>
20+
</template>

docs/content/3.components/modal.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,22 @@ name: 'modal-nested-example'
347347
---
348348
::
349349

350+
### Using the close method
351+
352+
All slots in the Modal component receive a `close` method that can be used to programmatically close the modal from within the content.
353+
354+
::component-example
355+
---
356+
name: 'modal-programatic-close'
357+
props:
358+
class: 'px-4'
359+
---
360+
::
361+
362+
::tip
363+
The `close` method is available in all slots: `default`, `content`, `header`, `title`, `description`, `body`, and `footer`.
364+
::
365+
350366
### With footer slot
351367

352368
Use the `#footer` slot to add content after the Modal's body.

playground/app/pages/components/modal.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,13 @@ function openModal() {
6969
</UModal>
7070

7171
<UButton label="Open programmatically" color="neutral" variant="outline" @click="openModal" />
72+
73+
<UModal title="First modal">
74+
<UButton color="neutral" variant="outline" label="Close with scoped close" />
75+
76+
<template #footer="{ close }">
77+
<UButton label="Close with scoped close" @click="close" />
78+
</template>
79+
</UModal>
7280
</div>
7381
</template>

src/runtime/components/Modal.vue

Lines changed: 75 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
<script lang="ts">
22
import type { DialogRootProps, DialogRootEmits, DialogContentProps, DialogContentEmits } from 'reka-ui'
3+
import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose, VisuallyHidden, useForwardPropsEmits } from 'reka-ui'
34
import type { AppConfig } from '@nuxt/schema'
45
import theme from '#build/ui/modal'
56
import type { ButtonProps } from '../types'
67
import type { EmitsToProps, ComponentConfig } from '../types/utils'
8+
import ModalContext from './ModalContext.vue'
79
810
type Modal = ComponentConfig<typeof theme, AppConfig, 'modal'>
911
@@ -61,19 +63,18 @@ export interface ModalEmits extends DialogRootEmits {
6163
6264
export interface ModalSlots {
6365
default(props: { open: boolean }): any
64-
content(props?: {}): any
65-
header(props?: {}): any
66-
title(props?: {}): any
67-
description(props?: {}): any
66+
content(props: { close: () => void }): any
67+
header(props: { close: () => void }): any
68+
title(props: { close: () => void }): any
69+
description(props: { close: () => void }): any
6870
close(props: { ui: { [K in keyof Required<Modal['slots']>]: (props?: Record<string, any>) => string } }): any
69-
body(props?: {}): any
70-
footer(props?: {}): any
71+
body(props: { close: () => void }): any
72+
footer(props: { close: () => void }): any
7173
}
7274
</script>
7375

7476
<script setup lang="ts">
7577
import { computed, toRef } from 'vue'
76-
import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose, VisuallyHidden, useForwardPropsEmits } from 'reka-ui'
7778
import { reactivePick } from '@vueuse/core'
7879
import { useAppConfig } from '#imports'
7980
import { useLocale } from '../composables/useLocale'
@@ -126,71 +127,73 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.modal || {})
126127

127128
<template>
128129
<DialogRoot v-slot="{ open }" v-bind="rootProps">
129-
<DialogTrigger v-if="!!slots.default" as-child :class="props.class">
130-
<slot :open="open" />
131-
</DialogTrigger>
132-
133-
<DialogPortal v-bind="portalProps">
134-
<DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />
135-
136-
<DialogContent :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-enter="emits('after:enter')" @after-leave="emits('after:leave')" v-on="contentEvents">
137-
<VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
138-
<DialogTitle v-if="title || !!slots.title">
139-
<slot name="title">
140-
{{ title }}
141-
</slot>
142-
</DialogTitle>
143-
144-
<DialogDescription v-if="description || !!slots.description">
145-
<slot name="description">
146-
{{ description }}
147-
</slot>
148-
</DialogDescription>
149-
</VisuallyHidden>
150-
151-
<slot name="content">
152-
<div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
153-
<slot name="header">
154-
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
155-
<DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
156-
<slot name="title">
157-
{{ title }}
158-
</slot>
159-
</DialogTitle>
160-
161-
<DialogDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
162-
<slot name="description">
163-
{{ description }}
130+
<ModalContext v-slot="{ onOpenChange }">
131+
<DialogTrigger v-if="!!slots.default" as-child :class="props.class">
132+
<slot :open="open" :close="() => onOpenChange(false)" />
133+
</DialogTrigger>
134+
135+
<DialogPortal v-bind="portalProps">
136+
<DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />
137+
138+
<DialogContent :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-enter="emits('after:enter')" @after-leave="emits('after:leave')" v-on="contentEvents">
139+
<VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
140+
<DialogTitle v-if="title || !!slots.title">
141+
<slot name="title" :close="() => onOpenChange(false)">
142+
{{ title }}
143+
</slot>
144+
</DialogTitle>
145+
146+
<DialogDescription v-if="description || !!slots.description">
147+
<slot name="description" :close="() => onOpenChange(false)">
148+
{{ description }}
149+
</slot>
150+
</DialogDescription>
151+
</VisuallyHidden>
152+
153+
<slot name="content" :close="() => onOpenChange(false)">
154+
<div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
155+
<slot name="header" :close="() => onOpenChange(false)">
156+
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
157+
<DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
158+
<slot name="title" :close="() => onOpenChange(false)">
159+
{{ title }}
160+
</slot>
161+
</DialogTitle>
162+
163+
<DialogDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
164+
<slot name="description" :close="() => onOpenChange(false)">
165+
{{ description }}
166+
</slot>
167+
</DialogDescription>
168+
</div>
169+
170+
<DialogClose v-if="close || !!slots.close" as-child>
171+
<slot name="close" :ui="ui">
172+
<UButton
173+
v-if="close"
174+
:icon="closeIcon || appConfig.ui.icons.close"
175+
size="md"
176+
color="neutral"
177+
variant="ghost"
178+
:aria-label="t('modal.close')"
179+
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
180+
:class="ui.close({ class: props.ui?.close })"
181+
/>
164182
</slot>
165-
</DialogDescription>
166-
</div>
167-
168-
<DialogClose v-if="close || !!slots.close" as-child>
169-
<slot name="close" :ui="ui">
170-
<UButton
171-
v-if="close"
172-
:icon="closeIcon || appConfig.ui.icons.close"
173-
size="md"
174-
color="neutral"
175-
variant="ghost"
176-
:aria-label="t('modal.close')"
177-
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
178-
:class="ui.close({ class: props.ui?.close })"
179-
/>
180-
</slot>
181-
</DialogClose>
182-
</slot>
183-
</div>
184-
185-
<div v-if="!!slots.body" :class="ui.body({ class: props.ui?.body })">
186-
<slot name="body" />
187-
</div>
188-
189-
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
190-
<slot name="footer" />
191-
</div>
192-
</slot>
193-
</DialogContent>
194-
</DialogPortal>
183+
</DialogClose>
184+
</slot>
185+
</div>
186+
187+
<div v-if="!!slots.body" :class="ui.body({ class: props.ui?.body })">
188+
<slot name="body" :close="() => onOpenChange(false)" />
189+
</div>
190+
191+
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
192+
<slot name="footer" :close="() => onOpenChange(false)" />
193+
</div>
194+
</slot>
195+
</DialogContent>
196+
</DialogPortal>
197+
</ModalContext>
195198
</DialogRoot>
196199
</template>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script setup lang="ts">
2+
import { injectDialogRootContext } from 'reka-ui'
3+
4+
const rootContext = injectDialogRootContext()
5+
type RootContext = typeof rootContext
6+
7+
defineEmits<{
8+
onOpenChange: RootContext['onOpenChange']
9+
}>()
10+
</script>
11+
12+
<template>
13+
<slot v-bind="rootContext" />
14+
</template>

0 commit comments

Comments
 (0)