diff --git a/docs/content/docs/2.components/modal.md b/docs/content/docs/2.components/modal.md index 622b54fb84..b29f8d78d7 100644 --- a/docs/content/docs/2.components/modal.md +++ b/docs/content/docs/2.components/modal.md @@ -360,6 +360,36 @@ slots: :placeholder{class="h-full"} :: +### Force Mount + +Use the `portal` prop with an object to force the Modal content to render even when closed. This is useful for SSR when the modal should be visible on initial page load. + +::component-code +--- +prettier: true +ignore: + - title +props: + portal: + to: false + forceMount: true + title: 'Modal with force mount' +slots: + default: | + + + + body: | + + +--- + +:u-button{label="Open" color="neutral" variant="subtle"} + +#body +:placeholder{class="h-48"} +:: + ## Examples ### Control open state diff --git a/src/runtime/components/ContextMenu.vue b/src/runtime/components/ContextMenu.vue index afba849fd5..2e2e064462 100644 --- a/src/runtime/components/ContextMenu.vue +++ b/src/runtime/components/ContextMenu.vue @@ -6,6 +6,7 @@ import theme from '#build/ui/context-menu' import type { AvatarProps, IconProps, KbdProps, LinkProps } from '../types' import type { ArrayOrNested, DynamicSlots, GetItemKeys, MergeTypes, NestedItem, EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type ContextMenu = ComponentConfig @@ -70,7 +71,7 @@ export interface ContextMenuProps = Arr * Render the menu in a portal. * @defaultValue true */ - portal?: boolean | string | HTMLElement + portal?: PortalProps /** * The key used to get the label from the item. * @defaultValue 'label' diff --git a/src/runtime/components/ContextMenuContent.vue b/src/runtime/components/ContextMenuContent.vue index 34b8177786..948931c044 100644 --- a/src/runtime/components/ContextMenuContent.vue +++ b/src/runtime/components/ContextMenuContent.vue @@ -5,12 +5,13 @@ import type theme from '#build/ui/context-menu' import type { AvatarProps, ContextMenuItem, ContextMenuSlots, IconProps, KbdProps } from '../types' import type { ArrayOrNested, GetItemKeys } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type ContextMenu = ComponentConfig interface ContextMenuContentProps> extends Omit { items?: T - portal?: boolean | string | HTMLElement + portal?: PortalProps sub?: boolean labelKey: GetItemKeys descriptionKey: GetItemKeys diff --git a/src/runtime/components/Drawer.vue b/src/runtime/components/Drawer.vue index d1cba385cf..af862f3b1c 100644 --- a/src/runtime/components/Drawer.vue +++ b/src/runtime/components/Drawer.vue @@ -5,6 +5,7 @@ import type { AppConfig } from '@nuxt/schema' import theme from '#build/ui/drawer' import type { EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type Drawer = ComponentConfig @@ -37,7 +38,7 @@ export interface DrawerProps extends Pick @@ -78,7 +79,7 @@ export interface DropdownMenuProps = A * Render the menu in a portal. * @defaultValue true */ - portal?: boolean | string | HTMLElement + portal?: PortalProps /** * The key used to get the label from the item. * @defaultValue 'label' diff --git a/src/runtime/components/DropdownMenuContent.vue b/src/runtime/components/DropdownMenuContent.vue index e89d644400..ed0a67ebd1 100644 --- a/src/runtime/components/DropdownMenuContent.vue +++ b/src/runtime/components/DropdownMenuContent.vue @@ -6,12 +6,13 @@ import type theme from '#build/ui/dropdown-menu' import type { KbdProps, AvatarProps, DropdownMenuItem, DropdownMenuSlots, IconProps } from '../types' import type { ArrayOrNested, GetItemKeys, NestedItem, DynamicSlots, MergeTypes } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type DropdownMenu = ComponentConfig interface DropdownMenuContentProps> extends Omit { items?: T - portal?: boolean | string | HTMLElement + portal?: PortalProps sub?: boolean labelKey: GetItemKeys descriptionKey: GetItemKeys diff --git a/src/runtime/components/InputMenu.vue b/src/runtime/components/InputMenu.vue index e500db032d..9a0f395671 100644 --- a/src/runtime/components/InputMenu.vue +++ b/src/runtime/components/InputMenu.vue @@ -8,6 +8,7 @@ import type { ModelModifiers } from '../types/input' import type { InputHTMLAttributes } from '../types/html' import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetItemValue, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type InputMenu = ComponentConfig @@ -104,7 +105,7 @@ export interface InputMenuProps = ArrayOr * Render the menu in a portal. * @defaultValue true */ - portal?: boolean | string | HTMLElement + portal?: PortalProps /** * Enable virtualization for large lists. * Note: when enabled, all groups are flattened into a single list due to a limitation of Reka UI (https://github.com/unovue/reka-ui/issues/1885). diff --git a/src/runtime/components/Modal.vue b/src/runtime/components/Modal.vue index 827f467846..97be1ecd83 100644 --- a/src/runtime/components/Modal.vue +++ b/src/runtime/components/Modal.vue @@ -5,6 +5,7 @@ import theme from '#build/ui/modal' import type { ButtonProps, IconProps, LinkPropsKeys } from '../types' import type { EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type Modal = ComponentConfig @@ -37,7 +38,7 @@ export interface ModalProps extends DialogRootProps { * Render the modal in a portal. * @defaultValue true */ - portal?: boolean | string | HTMLElement + portal?: PortalProps /** * Display a close button to dismiss the modal. * `{ size: 'md', color: 'neutral', variant: 'ghost' }`{lang="ts-type"} diff --git a/src/runtime/components/Popover.vue b/src/runtime/components/Popover.vue index fa3e6636c6..d5494a6c23 100644 --- a/src/runtime/components/Popover.vue +++ b/src/runtime/components/Popover.vue @@ -4,6 +4,7 @@ import type { AppConfig } from '@nuxt/schema' import theme from '#build/ui/popover' import type { EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type Popover = ComponentConfig type PopoverMode = 'click' | 'hover' @@ -28,7 +29,7 @@ export interface PopoverProps extends Popov * Render the popover in a portal. * @defaultValue true */ - portal?: boolean | string | HTMLElement + portal?: PortalProps /** * The reference (or anchor) element that is being referred to for positioning. * diff --git a/src/runtime/components/Select.vue b/src/runtime/components/Select.vue index 3d3bb53235..6b06e4f8a1 100644 --- a/src/runtime/components/Select.vue +++ b/src/runtime/components/Select.vue @@ -8,6 +8,7 @@ import type { ModelModifiers } from '../types/input' import type { ButtonHTMLAttributes } from '../types/html' import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetItemValue, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type Select = ComponentConfig @@ -77,7 +78,7 @@ export interface SelectProps = ArrayOrNested * Render the menu in a portal. * @defaultValue true */ - portal?: boolean | string | HTMLElement + portal?: PortalProps /** * When `items` is an array of objects, select the field to use as the value. * @defaultValue 'value' diff --git a/src/runtime/components/SelectMenu.vue b/src/runtime/components/SelectMenu.vue index dba7593f86..0e27fe7ab0 100644 --- a/src/runtime/components/SelectMenu.vue +++ b/src/runtime/components/SelectMenu.vue @@ -8,6 +8,7 @@ import type { ModelModifiers } from '../types/input' import type { ButtonHTMLAttributes } from '../types/html' import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetItemValue, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type SelectMenu = ComponentConfig @@ -97,7 +98,7 @@ export interface SelectMenuProps = Array * Render the menu in a portal. * @defaultValue true */ - portal?: boolean | string | HTMLElement + portal?: PortalProps /** * Enable virtualization for large lists. * Note: when enabled, all groups are flattened into a single list due to a limitation of Reka UI (https://github.com/unovue/reka-ui/issues/1885). diff --git a/src/runtime/components/Slideover.vue b/src/runtime/components/Slideover.vue index 0c12ea0e28..318e0b9e6a 100644 --- a/src/runtime/components/Slideover.vue +++ b/src/runtime/components/Slideover.vue @@ -5,6 +5,7 @@ import theme from '#build/ui/slideover' import type { ButtonProps, IconProps, LinkPropsKeys } from '../types' import type { EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type Slideover = ComponentConfig @@ -37,7 +38,7 @@ export interface SlideoverProps extends DialogRootProps { * Render the slideover in a portal. * @defaultValue true */ - portal?: boolean | string | HTMLElement + portal?: PortalProps /** * Display a close button to dismiss the slideover. * `{ size: 'md', color: 'neutral', variant: 'ghost' }`{lang="ts-type"} diff --git a/src/runtime/components/Toaster.vue b/src/runtime/components/Toaster.vue index 66c56adcf1..68d3a05499 100644 --- a/src/runtime/components/Toaster.vue +++ b/src/runtime/components/Toaster.vue @@ -3,6 +3,7 @@ import type { ToastProviderProps } from 'reka-ui' import type { AppConfig } from '@nuxt/schema' import theme from '#build/ui/toaster' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type Toaster = ComponentConfig @@ -26,7 +27,7 @@ export interface ToasterProps extends Omit * Render the toaster in a portal. * @defaultValue true */ - portal?: boolean | string | HTMLElement + portal?: PortalProps /** * Maximum number of toasts to display at once. * @defaultValue 5 diff --git a/src/runtime/components/Tooltip.vue b/src/runtime/components/Tooltip.vue index d10a0598f6..3323db6022 100644 --- a/src/runtime/components/Tooltip.vue +++ b/src/runtime/components/Tooltip.vue @@ -5,6 +5,7 @@ import theme from '#build/ui/tooltip' import type { KbdProps } from '../types' import type { EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' +import type { PortalProps } from '../composables/usePortal' type Tooltip = ComponentConfig @@ -27,7 +28,7 @@ export interface TooltipProps extends TooltipRootProps { * Render the tooltip in a portal. * @defaultValue true */ - portal?: boolean | string | HTMLElement + portal?: PortalProps /** * The reference (or anchor) element that is being referred to for positioning. * diff --git a/src/runtime/composables/usePortal.ts b/src/runtime/composables/usePortal.ts index 9d39384ae9..8f2364119e 100644 --- a/src/runtime/composables/usePortal.ts +++ b/src/runtime/composables/usePortal.ts @@ -1,18 +1,61 @@ import { inject, computed } from 'vue' import type { Ref, InjectionKey } from 'vue' +import type { DialogPortalProps } from 'reka-ui' export const portalTargetInjectionKey: InjectionKey> = Symbol('nuxt-ui.portal-target') -export function usePortal(portal: Ref) { +export type PortalProps = boolean | string | HTMLElement | DialogPortalProps + +function isDialogPortalProps(p: unknown): p is DialogPortalProps { + return typeof p === 'object' && p !== null && ('to' in p || 'disabled' in p || 'defer' in p || 'forceMount' in p) +} + +export function usePortal(portal: Ref) { const globalPortal = inject(portalTargetInjectionKey, undefined) - const value = computed(() => portal.value === true ? globalPortal?.value : portal.value) + const value = computed((): boolean | string | HTMLElement | undefined => { + const p = portal.value + + if (p === true) { + return globalPortal?.value + } + + if (isDialogPortalProps(p)) { + return p.to + } + + return p + }) + + const disabled = computed(() => { + const p = portal.value + + if (isDialogPortalProps(p) && p.disabled !== undefined) { + return p.disabled + } + + return typeof value.value === 'boolean' ? !value.value : false + }) + + const to = computed(() => { + if (isDialogPortalProps(portal.value) && (typeof portal.value?.to === 'boolean' || typeof portal.value?.to === 'undefined')) { + return 'body' + } + + if (typeof value.value === 'boolean') { + return 'body' + } - const disabled = computed(() => typeof value.value === 'boolean' ? !value.value : false) - const to = computed(() => typeof value.value === 'boolean' ? 'body' : value.value) + return value.value + }) + const forceMount = computed(() => { + const p = portal.value + return isDialogPortalProps(p) && p.forceMount === true + }) return computed(() => ({ to: to.value, - disabled: disabled.value + disabled: disabled.value, + forceMount: forceMount.value })) } diff --git a/test/components/Modal.spec.ts b/test/components/Modal.spec.ts index 1a770fc62d..5da979c58c 100644 --- a/test/components/Modal.spec.ts +++ b/test/components/Modal.spec.ts @@ -23,6 +23,7 @@ describe('Modal', () => { ['with closeIcon', { props: { ...props, closeIcon: 'i-lucide-trash' } }], ['with class', { props: { ...props, class: 'bg-elevated' } }], ['with ui', { props: { ...props, ui: { close: 'end-2' } } }], + ['with forceMount', { props: { ...props, portal: { forceMount: true, disabled: true } } }], // Slots ['with default slot', { props, slots: { default: () => 'Default slot' } }], ['with content slot', { props, slots: { content: () => 'Content slot' } }], diff --git a/test/components/__snapshots__/Modal-vue.spec.ts.snap b/test/components/__snapshots__/Modal-vue.spec.ts.snap index 50a3f99f6c..53b2596cf7 100644 --- a/test/components/__snapshots__/Modal-vue.spec.ts.snap +++ b/test/components/__snapshots__/Modal-vue.spec.ts.snap @@ -253,6 +253,33 @@ exports[`Modal > renders with footer slot correctly 1`] = ` +" +`; + +exports[`Modal > renders with forceMount correctly 1`] = ` +" + + + + +
+ + + + " `; diff --git a/test/components/__snapshots__/Modal.spec.ts.snap b/test/components/__snapshots__/Modal.spec.ts.snap index 4704624553..aa4ad870d9 100644 --- a/test/components/__snapshots__/Modal.spec.ts.snap +++ b/test/components/__snapshots__/Modal.spec.ts.snap @@ -253,6 +253,33 @@ exports[`Modal > renders with footer slot correctly 1`] = ` +" +`; + +exports[`Modal > renders with forceMount correctly 1`] = ` +" + + + + +
+ + + + " `;