Skip to content

Commit 170b6fe

Browse files
committed
feat(portal): add configurable portalTarget support
Introduced a `portalTarget` option in `UApp` to specify portal mounting targets across various components. Updated portals in components like `ContextMenu`, `Drawer`, `Tooltip`, and others to respect `portalTarget`. Default target is set to `body`.
1 parent 8471fb9 commit 170b6fe

File tree

14 files changed

+95
-37
lines changed

14 files changed

+95
-37
lines changed

src/runtime/components/App.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface AppProps<T extends Messages = Messages> extends Omit<ConfigProv
66
tooltip?: TooltipProviderProps
77
toaster?: ToasterProps | null
88
locale?: Locale<T>
9+
portalTarget?: string | HTMLElement
910
}
1011
1112
export interface AppSlots {
@@ -18,7 +19,7 @@ export default {
1819
</script>
1920

2021
<script setup lang="ts" generic="T extends Messages">
21-
import { toRef, useId, provide } from 'vue'
22+
import { toRef, useId, provide, computed } from 'vue'
2223
import { ConfigProvider, TooltipProvider, useForwardProps } from 'reka-ui'
2324
import { reactivePick } from '@vueuse/core'
2425
import { localeContextInjectionKey } from '../composables/useLocale'
@@ -34,6 +35,9 @@ const toasterProps = toRef(() => props.toaster)
3435
3536
const locale = toRef(() => props.locale)
3637
provide(localeContextInjectionKey, locale)
38+
39+
const portalTarget = computed(() => props.portalTarget ?? 'body')
40+
provide('portalTarget', portalTarget)
3741
</script>
3842

3943
<template>

src/runtime/components/ContextMenu.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export interface ContextMenuProps<T extends ArrayOrNested<ContextMenuItem> = Arr
8080
* Render the menu in a portal.
8181
* @defaultValue true
8282
*/
83-
portal?: boolean
83+
portal?: boolean | string | HTMLElement
8484
/**
8585
* The key used to get the label from the item.
8686
* @defaultValue 'label'

src/runtime/components/ContextMenuContent.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import type { ContextMenuContentProps as RekaContextMenuContentProps, ContextMenuContentEmits as RekaContextMenuContentEmits } from 'reka-ui'
3+
import type { ComputedRef } from 'vue'
34
import theme from '#build/ui/context-menu'
45
import { tv } from '../utils/tv'
56
import type { AvatarProps, ContextMenuItem, ContextMenuSlots, KbdProps } from '../types'
@@ -9,7 +10,7 @@ const _contextMenu = tv(theme)()
910
1011
interface ContextMenuContentProps<T extends ArrayOrNested<ContextMenuItem>> extends Omit<RekaContextMenuContentProps, 'as' | 'asChild' | 'forceMount'> {
1112
items?: T
12-
portal?: boolean
13+
portal?: boolean | string | HTMLElement
1314
sub?: boolean
1415
labelKey: keyof NestedItem<T>
1516
/**
@@ -33,7 +34,7 @@ interface ContextMenuContentEmits extends RekaContextMenuContentEmits {}
3334
</script>
3435

3536
<script setup lang="ts" generic="T extends ArrayOrNested<ContextMenuItem>">
36-
import { computed } from 'vue'
37+
import { computed, inject } from 'vue'
3738
import { ContextMenu } from 'reka-ui/namespaced'
3839
import { useForwardPropsEmits } from 'reka-ui'
3940
import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
@@ -65,6 +66,10 @@ const groups = computed<ContextMenuItem[][]>(() =>
6566
: [props.items]
6667
: []
6768
)
69+
70+
const injectedPortalTarget = inject<ComputedRef<string | HTMLElement> | undefined>('portalTarget')
71+
const portalTarget = computed(() => typeof props.portal === 'string' ? props.portal : injectedPortalTarget?.value ?? 'body')
72+
const portalDisabled = computed(() => typeof props.portal === 'boolean' ? !props.portal : false)
6873
</script>
6974

7075
<template>
@@ -99,7 +104,7 @@ const groups = computed<ContextMenuItem[][]>(() =>
99104
</slot>
100105
</DefineItemTemplate>
101106

102-
<ContextMenu.Portal :disabled="!portal">
107+
<ContextMenu.Portal :disabled="portalDisabled" :to="portalTarget">
103108
<component :is="sub ? ContextMenu.SubContent : ContextMenu.Content" :class="props.class" v-bind="contentProps">
104109
<ContextMenu.Group v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: uiOverride?.group })">
105110
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">

src/runtime/components/Drawer.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type { DrawerRootProps, DrawerRootEmits } from 'vaul-vue'
33
import type { DialogContentProps, DialogContentEmits } from 'reka-ui'
44
import type { AppConfig } from '@nuxt/schema'
5+
import type { ComputedRef } from 'vue'
56
import _appConfig from '#build/app.config'
67
import theme from '#build/ui/drawer'
78
import { tv } from '../utils/tv'
@@ -40,7 +41,7 @@ export interface DrawerProps extends Pick<DrawerRootProps, 'activeSnapPoint' | '
4041
* Render the drawer in a portal.
4142
* @defaultValue true
4243
*/
43-
portal?: boolean
44+
portal?: boolean | string | HTMLElement
4445
class?: any
4546
ui?: Partial<typeof drawer.slots>
4647
}
@@ -59,7 +60,7 @@ export interface DrawerSlots {
5960
</script>
6061

6162
<script setup lang="ts">
62-
import { computed, toRef } from 'vue'
63+
import { computed, inject, toRef } from 'vue'
6364
import { useForwardPropsEmits } from 'reka-ui'
6465
import { DrawerRoot, DrawerTrigger, DrawerPortal, DrawerOverlay, DrawerContent, DrawerTitle, DrawerDescription, DrawerHandle } from 'vaul-vue'
6566
import { reactivePick } from '@vueuse/core'
@@ -85,6 +86,10 @@ const ui = computed(() => drawer({
8586
direction: props.direction,
8687
inset: props.inset
8788
}))
89+
90+
const injectedPortalTarget = inject<ComputedRef<string | HTMLElement> | undefined>('portalTarget')
91+
const portalTarget = computed(() => typeof props.portal === 'string' ? props.portal : injectedPortalTarget?.value ?? 'body')
92+
const portalDisabled = computed(() => typeof props.portal === 'boolean' ? !props.portal : false)
8893
</script>
8994

9095
<template>
@@ -93,7 +98,7 @@ const ui = computed(() => drawer({
9398
<slot />
9499
</DrawerTrigger>
95100

96-
<DrawerPortal :disabled="!portal">
101+
<DrawerPortal :disabled="portalDisabled" :to="portalTarget">
97102
<DrawerOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />
98103

99104
<DrawerContent :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" v-on="contentEvents">

src/runtime/components/DropdownMenu.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export interface DropdownMenuProps<T extends ArrayOrNested<DropdownMenuItem> = A
8888
* Render the menu in a portal.
8989
* @defaultValue true
9090
*/
91-
portal?: boolean
91+
portal?: boolean | string | HTMLElement
9292
/**
9393
* The key used to get the label from the item.
9494
* @defaultValue 'label'

src/runtime/components/DropdownMenuContent.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<!-- eslint-disable vue/block-tag-newline -->
22
<script lang="ts">
33
import type { DropdownMenuContentProps as RekaDropdownMenuContentProps, DropdownMenuContentEmits as RekaDropdownMenuContentEmits } from 'reka-ui'
4+
import type { ComputedRef } from 'vue'
45
import theme from '#build/ui/dropdown-menu'
56
import { tv } from '../utils/tv'
67
import type { KbdProps, AvatarProps, DropdownMenuItem, DropdownMenuSlots } from '../types'
@@ -10,7 +11,7 @@ const _dropdownMenu = tv(theme)()
1011
1112
interface DropdownMenuContentProps<T extends ArrayOrNested<DropdownMenuItem>> extends Omit<RekaDropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> {
1213
items?: T
13-
portal?: boolean
14+
portal?: boolean | string | HTMLElement
1415
sub?: boolean
1516
labelKey: keyof NestedItem<T>
1617
/**
@@ -39,7 +40,7 @@ type DropdownMenuContentSlots<T extends ArrayOrNested<DropdownMenuItem>> = Omit<
3940
</script>
4041

4142
<script setup lang="ts" generic="T extends ArrayOrNested<DropdownMenuItem>">
42-
import { computed } from 'vue'
43+
import { computed, inject } from 'vue'
4344
import { DropdownMenu } from 'reka-ui/namespaced'
4445
import { useForwardPropsEmits } from 'reka-ui'
4546
import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
@@ -71,6 +72,10 @@ const groups = computed<DropdownMenuItem[][]>(() =>
7172
: [props.items]
7273
: []
7374
)
75+
76+
const injectedPortalTarget = inject<ComputedRef<string | HTMLElement> | undefined>('portalTarget')
77+
const portalTarget = computed(() => typeof props.portal === 'string' ? props.portal : injectedPortalTarget?.value ?? 'body')
78+
const portalDisabled = computed(() => typeof props.portal === 'boolean' ? !props.portal : false)
7479
</script>
7580

7681
<template>
@@ -105,7 +110,7 @@ const groups = computed<DropdownMenuItem[][]>(() =>
105110
</slot>
106111
</DefineItemTemplate>
107112

108-
<DropdownMenu.Portal :disabled="!portal">
113+
<DropdownMenu.Portal :disabled="portalDisabled" :to="portalTarget">
109114
<component :is="sub ? DropdownMenu.SubContent : DropdownMenu.Content" :class="props.class" v-bind="contentProps">
110115
<DropdownMenu.Group v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: uiOverride?.group })">
111116
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">

src/runtime/components/InputMenu.vue

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import type { InputHTMLAttributes } from 'vue'
2+
import type { InputHTMLAttributes, ComputedRef } from 'vue'
33
import type { VariantProps } from 'tailwind-variants'
44
import type { ComboboxRootProps, ComboboxRootEmits, ComboboxContentProps, ComboboxContentEmits, ComboboxArrowProps } from 'reka-ui'
55
import type { AppConfig } from '@nuxt/schema'
@@ -103,7 +103,7 @@ export interface InputMenuProps<T extends ArrayOrNested<InputMenuItem> = ArrayOr
103103
* Render the menu in a portal.
104104
* @defaultValue true
105105
*/
106-
portal?: boolean
106+
portal?: boolean | string | HTMLElement
107107
/**
108108
* When `items` is an array of objects, select the field to use as the value instead of the object itself.
109109
* @defaultValue undefined
@@ -176,7 +176,7 @@ export interface InputMenuSlots<
176176
</script>
177177

178178
<script setup lang="ts" generic="T extends ArrayOrNested<InputMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false">
179-
import { computed, ref, toRef, onMounted, toRaw, nextTick } from 'vue'
179+
import { computed, ref, toRef, onMounted, toRaw, nextTick, inject } from 'vue'
180180
import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits, useFilter } from 'reka-ui'
181181
import { defu } from 'defu'
182182
import { isEqual } from 'ohash/utils'
@@ -354,6 +354,10 @@ function isInputItem(item: InputMenuItem): item is _InputMenuItem {
354354
defineExpose({
355355
inputRef
356356
})
357+
358+
const injectedPortalTarget = inject<ComputedRef<string | HTMLElement> | undefined>('portalTarget')
359+
const portalTarget = computed(() => typeof props.portal === 'string' ? props.portal : injectedPortalTarget?.value ?? 'body')
360+
const portalDisabled = computed(() => typeof props.portal === 'boolean' ? !props.portal : false)
357361
</script>
358362

359363
<!-- eslint-disable vue/no-template-shadow -->
@@ -452,7 +456,7 @@ defineExpose({
452456
</ComboboxTrigger>
453457
</ComboboxAnchor>
454458

455-
<ComboboxPortal :disabled="!portal">
459+
<ComboboxPortal :disabled="portalDisabled" :to="portalTarget">
456460
<ComboboxContent :class="ui.content({ class: props.ui?.content })" v-bind="contentProps">
457461
<ComboboxEmpty :class="ui.empty({ class: props.ui?.empty })">
458462
<slot name="empty" :search-term="searchTerm">

src/runtime/components/Modal.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import type { DialogRootProps, DialogRootEmits, DialogContentProps, DialogContentEmits } from 'reka-ui'
33
import type { AppConfig } from '@nuxt/schema'
4+
import type { ComputedRef } from 'vue'
45
import _appConfig from '#build/app.config'
56
import theme from '#build/ui/modal'
67
import { tv } from '../utils/tv'
@@ -35,7 +36,7 @@ export interface ModalProps extends DialogRootProps {
3536
* Render the modal in a portal.
3637
* @defaultValue true
3738
*/
38-
portal?: boolean
39+
portal?: boolean | string | HTMLElement
3940
/**
4041
* Display a close button to dismiss the modal.
4142
* `{ size: 'md', color: 'neutral', variant: 'ghost' }`{lang="ts-type"}
@@ -74,7 +75,7 @@ export interface ModalSlots {
7475
</script>
7576

7677
<script setup lang="ts">
77-
import { computed, toRef } from 'vue'
78+
import { computed, inject, toRef } from 'vue'
7879
import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose, VisuallyHidden, useForwardPropsEmits } from 'reka-ui'
7980
import { reactivePick } from '@vueuse/core'
8081
import { useAppConfig } from '#imports'
@@ -118,6 +119,10 @@ const ui = computed(() => modal({
118119
transition: props.transition,
119120
fullscreen: props.fullscreen
120121
}))
122+
123+
const injectedPortalTarget = inject<ComputedRef<string | HTMLElement> | undefined>('portalTarget')
124+
const portalTarget = computed(() => typeof props.portal === 'string' ? props.portal : injectedPortalTarget?.value ?? 'body')
125+
const portalDisabled = computed(() => typeof props.portal === 'boolean' ? !props.portal : false)
121126
</script>
122127

123128
<template>
@@ -126,7 +131,7 @@ const ui = computed(() => modal({
126131
<slot :open="open" />
127132
</DialogTrigger>
128133

129-
<DialogPortal :disabled="!portal">
134+
<DialogPortal :disabled="portalDisabled" :to="portalTarget">
130135
<DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />
131136

132137
<DialogContent :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">

src/runtime/components/Popover.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import type { PopoverRootProps, HoverCardRootProps, PopoverRootEmits, PopoverContentProps, PopoverContentEmits, PopoverArrowProps } from 'reka-ui'
33
import type { AppConfig } from '@nuxt/schema'
4+
import type { ComputedRef } from 'vue'
45
import _appConfig from '#build/app.config'
56
import theme from '#build/ui/popover'
67
import { tv } from '../utils/tv'
@@ -30,7 +31,7 @@ export interface PopoverProps extends PopoverRootProps, Pick<HoverCardRootProps,
3031
* Render the popover in a portal.
3132
* @defaultValue true
3233
*/
33-
portal?: boolean
34+
portal?: boolean | string | HTMLElement
3435
/**
3536
* When `false`, the popover will not close when clicking outside or pressing escape.
3637
* @defaultValue true
@@ -49,7 +50,7 @@ export interface PopoverSlots {
4950
</script>
5051

5152
<script setup lang="ts">
52-
import { computed, toRef } from 'vue'
53+
import { computed, inject, toRef } from 'vue'
5354
import { defu } from 'defu'
5455
import { useForwardPropsEmits } from 'reka-ui'
5556
import { Popover, HoverCard } from 'reka-ui/namespaced'
@@ -87,6 +88,10 @@ const ui = computed(() => popover({
8788
}))
8889
8990
const Component = computed(() => props.mode === 'hover' ? HoverCard : Popover)
91+
92+
const injectedPortalTarget = inject<ComputedRef<string | HTMLElement> | undefined>('portalTarget')
93+
const portalTarget = computed(() => typeof props.portal === 'string' ? props.portal : injectedPortalTarget?.value ?? 'body')
94+
const portalDisabled = computed(() => typeof props.portal === 'boolean' ? !props.portal : false)
9095
</script>
9196

9297
<template>
@@ -95,7 +100,7 @@ const Component = computed(() => props.mode === 'hover' ? HoverCard : Popover)
95100
<slot :open="open" />
96101
</Component.Trigger>
97102

98-
<Component.Portal :disabled="!portal">
103+
<Component.Portal :disabled="portalDisabled" :to="portalTarget">
99104
<Component.Content v-bind="contentProps" :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-on="contentEvents">
100105
<slot name="content" />
101106

src/runtime/components/Select.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type { VariantProps } from 'tailwind-variants'
33
import type { SelectRootProps, SelectRootEmits, SelectContentProps, SelectContentEmits, SelectArrowProps } from 'reka-ui'
44
import type { AppConfig } from '@nuxt/schema'
5+
import type { ComputedRef } from 'vue'
56
import _appConfig from '#build/app.config'
67
import theme from '#build/ui/select'
78
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
@@ -86,7 +87,7 @@ export interface SelectProps<T extends ArrayOrNested<SelectItem> = ArrayOrNested
8687
* Render the menu in a portal.
8788
* @defaultValue true
8889
*/
89-
portal?: boolean
90+
portal?: boolean | string | HTMLElement
9091
/**
9192
* When `items` is an array of objects, select the field to use as the value.
9293
* @defaultValue 'value'
@@ -135,7 +136,7 @@ export interface SelectSlots<
135136
</script>
136137

137138
<script setup lang="ts" generic="T extends ArrayOrNested<SelectItem>, VK extends GetItemKeys<T> = 'value', M extends boolean = false">
138-
import { computed, toRef } from 'vue'
139+
import { computed, inject, toRef } from 'vue'
139140
import { SelectRoot, SelectArrow, SelectTrigger, SelectPortal, SelectContent, SelectViewport, SelectLabel, SelectGroup, SelectItem, SelectItemIndicator, SelectItemText, SelectSeparator, useForwardPropsEmits } from 'reka-ui'
140141
import { defu } from 'defu'
141142
import { reactivePick } from '@vueuse/core'
@@ -222,6 +223,10 @@ function onUpdateOpen(value: boolean) {
222223
function isSelectItem(item: SelectItem): item is SelectItemBase {
223224
return typeof item === 'object' && item !== null
224225
}
226+
227+
const injectedPortalTarget = inject<ComputedRef<string | HTMLElement> | undefined>('portalTarget')
228+
const portalTarget = computed(() => typeof props.portal === 'string' ? props.portal : injectedPortalTarget?.value ?? 'body')
229+
const portalDisabled = computed(() => typeof props.portal === 'boolean' ? !props.portal : false)
225230
</script>
226231

227232
<!-- eslint-disable vue/no-template-shadow -->
@@ -263,7 +268,7 @@ function isSelectItem(item: SelectItem): item is SelectItemBase {
263268
</span>
264269
</SelectTrigger>
265270

266-
<SelectPortal :disabled="!portal">
271+
<SelectPortal :disabled="portalDisabled" :to="portalTarget">
267272
<SelectContent :class="ui.content({ class: props.ui?.content })" v-bind="contentProps">
268273
<SelectViewport :class="ui.viewport({ class: props.ui?.viewport })">
269274
<SelectGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">

0 commit comments

Comments
 (0)