Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/content/docs/2.components/modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: |

<UButton label="Open" color="neutral" variant="subtle" />

body: |

<Placeholder class="h-48" />
---

:u-button{label="Open" color="neutral" variant="subtle"}

#body
:placeholder{class="h-48"}
::
Comment on lines +363 to +391
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Fix markdownlint MD007/MD018 in the Force Mount example.

Line 371 (MD007) and Line 389 (MD018) are flagged in this new block. Consider scoping a markdownlint disable to this component-code snippet.

πŸ› οΈ Suggested fix
 ### Force Mount
+
+<!-- markdownlint-disable MD007 MD018 -->
 ::component-code
 ---
 prettier: true
 ignore:
   - title
 props:
   portal:
     to: false
     forceMount: true
   title: 'Modal with force mount'
@@
 `#body`
 :placeholder{class="h-48"}
 ::
+<!-- markdownlint-enable MD007 MD018 -->
🧰 Tools
πŸͺ› markdownlint-cli2 (0.20.0)

371-371: Unordered list indentation
Expected: 0; Actual: 2

(MD007, ul-indent)


389-389: No space after hash on atx style heading

(MD018, no-missing-space-atx)

πŸ€– Prompt for AI Agents
In `@docs/content/docs/2.components/modal.md` around lines 363 - 391, The new
"Force Mount" component-code block triggers markdownlint MD007/MD018; scope a
markdownlint disable to just this snippet by wrapping the component-code block
with per-snippet directives (e.g., add a short markdownlint-disable/enable
comment immediately before and after the component-code block) so lint rules are
suppressed only for this example; look for the ":::component-code" snippet that
contains the portal prop (portal: { to: false, forceMount: true }) and apply the
scoped disable/enable around it to fix MD007/MD018 without affecting other docs.


## Examples

### Control open state
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/components/Modal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 { PortalProp } from '../composables/usePortal'
Comment thread
maxarias-io marked this conversation as resolved.
Outdated

type Modal = ComponentConfig<typeof theme, AppConfig, 'modal'>

Expand Down Expand Up @@ -37,7 +38,7 @@ export interface ModalProps extends DialogRootProps {
* Render the modal in a portal.
* @defaultValue true
*/
portal?: boolean | string | HTMLElement
portal?: PortalProp
Comment thread
maxarias-io marked this conversation as resolved.
Outdated
Comment thread
maxarias-io marked this conversation as resolved.
Outdated
/**
* Display a close button to dismiss the modal.
* `{ size: 'md', color: 'neutral', variant: 'ghost' }`{lang="ts-type"}
Expand Down
28 changes: 25 additions & 3 deletions src/runtime/composables/usePortal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,38 @@ import type { Ref, InjectionKey } from 'vue'

export const portalTargetInjectionKey: InjectionKey<Ref<boolean | string | HTMLElement>> = Symbol('nuxt-ui.portal-target')

export function usePortal(portal: Ref<boolean | string | HTMLElement | undefined>) {
export type PortalProp = boolean | string | HTMLElement | { to?: boolean | string | HTMLElement, forceMount?: boolean }
Comment thread
maxarias-io marked this conversation as resolved.
Outdated
Comment thread
maxarias-io marked this conversation as resolved.
Outdated

export function usePortal(portal: Ref<PortalProp | undefined>) {
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 (typeof p === 'object' && p !== null && !(p instanceof HTMLElement)) {
if (p.to === true) {
return globalPortal?.value
}
return p.to
}

return p
})

const disabled = computed(() => typeof value.value === 'boolean' ? !value.value : false)
const to = computed(() => typeof value.value === 'boolean' ? 'body' : value.value)
const forceMount = computed(() => {
const p = portal.value
return typeof p === 'object' && p !== null && !(p instanceof HTMLElement) && p.forceMount === true
})

return computed(() => ({
to: to.value,
disabled: disabled.value
disabled: disabled.value,
forceMount: forceMount.value
}))
Comment on lines +51 to 60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "usePortal.ts" -o -name "*Portal*" | head -20

Repository: nuxt/ui

Length of output: 89


🏁 Script executed:

cat -n ./src/runtime/composables/usePortal.ts

Repository: nuxt/ui

Length of output: 2141


🏁 Script executed:

rg "usePortal" -A 5 -B 2 --type ts | head -50

Repository: nuxt/ui

Length of output: 588


🏁 Script executed:

rg "from.*usePortal|import.*usePortal" --type ts -A 3

Repository: nuxt/ui

Length of output: 33


🏁 Script executed:

rg "usePortal" --type ts -B 2 -A 5

Repository: nuxt/ui

Length of output: 588


🏁 Script executed:

fd -e ts -e tsx | xargs grep -l "usePortal" 2>/dev/null | head -10

Repository: nuxt/ui

Length of output: 87


🏁 Script executed:

fd -type f -name "*Dialog*" -o -name "*dialog*" | grep -E "\.(ts|tsx|vue)$"

Repository: nuxt/ui

Length of output: 221


🏁 Script executed:

fd "Dialog|dialog" -e ts -e tsx -e vue

Repository: nuxt/ui

Length of output: 33


🏁 Script executed:

ls -la src/runtime/ && echo "---" && find src/runtime -maxdepth 2 -type d

Repository: nuxt/ui

Length of output: 1121


🏁 Script executed:

ls src/runtime/components/ && echo "---" && find src/runtime/components -maxdepth 1 -type d

Repository: nuxt/ui

Length of output: 1925


🏁 Script executed:

cat -n src/runtime/components/Modal.vue | head -100

Repository: nuxt/ui

Length of output: 3880


🏁 Script executed:

cat -n src/runtime/components/Modal.vue | sed -n '100,200p'

Repository: nuxt/ui

Length of output: 4653


🏁 Script executed:

cat -n src/runtime/components/Modal.vue | sed -n '200,250p'

Repository: nuxt/ui

Length of output: 1270


🏁 Script executed:

grep -r "DialogPortal" --include="*.ts" --include="*.tsx" -A 5 -B 2 | head -50

Repository: nuxt/ui

Length of output: 3151


🏁 Script executed:

cat package.json | grep -A 20 "dependencies"

Repository: nuxt/ui

Length of output: 835


🏁 Script executed:

cat package.json | grep "reka-ui"

Repository: nuxt/ui

Length of output: 74


Forward defer property from portal props to DialogPortal component.

Line 10 of the guard confirms defer is a valid property in DialogPortalProps, but lines 56-60 show it is not forwarded in the returned object. This causes portal: { defer: true } to be silently ignored. Since forceMount is forwarded, defer should be handled consistently:

Suggested fix
+  const defer = computed(() => {
+    const p = portal.value
+    return isDialogPortalProps(p) ? p.defer : undefined
+  })
   return computed(() => ({
     to: to.value,
     disabled: disabled.value,
-    forceMount: forceMount.value
+    forceMount: forceMount.value,
+    defer: defer.value
   }))
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}))
const forceMount = computed(() => {
const p = portal.value
return isDialogPortalProps(p) && p.forceMount === true
})
const defer = computed(() => {
const p = portal.value
return isDialogPortalProps(p) ? p.defer : undefined
})
return computed(() => ({
to: to.value,
disabled: disabled.value,
forceMount: forceMount.value,
defer: defer.value
}))
πŸ€– Prompt for AI Agents
In `@src/runtime/composables/usePortal.ts` around lines 51 - 60, The returned
computed object omits the defer flag from DialogPortalProps, so when
portal.value is a DialogPortalProps the defer true/false is ignored; update the
computed return (the function that currently returns { to: to.value, disabled:
disabled.value, forceMount: forceMount.value }) to also include defer, e.g.
compute defer the same way as forceMount by checking portal via
isDialogPortalProps(portal.value) and forwarding p.defer (or false if absent),
ensuring the new property name is defer and handled alongside forceMount, to and
disabled.

}
1 change: 1 addition & 0 deletions test/components/Modal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
['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 } } }],
// Slots
['with default slot', { props, slots: { default: () => 'Default slot' } }],
['with content slot', { props, slots: { content: () => 'Content slot' } }],
Expand All @@ -35,7 +36,7 @@
['with footer slot', { props, slots: { footer: () => 'Footer slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: ModalProps, slots?: Partial<ModalSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, Modal)
expect(html).toMatchSnapshot()

Check failure on line 39 in test/components/Modal.spec.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

../components/Modal.spec.ts > Modal > renders with forceMount correctly

Error: Snapshot `Modal > renders with forceMount correctly 1` mismatched - Expected + Received "<!--v-if--> <!--teleport start--> - - - - <div data-state="open" style="pointer-events: auto;" data-slot="overlay" class="fixed inset-0 data-[state=open]:animate-[fade-in_200ms_ease-out] data-[state=closed]:animate-[fade-out_200ms_ease-in] bg-elevated/75"></div> - <div data-dismissable-layer="" style="pointer-events: auto;" tabindex="-1" data-slot="content" class="bg-default divide-y divide-default flex flex-col focus:outline-none data-[state=open]:animate-[scale-in_200ms_ease-out] data-[state=closed]:animate-[scale-out_200ms_ease-in] w-[calc(100vw-2rem)] max-w-lg rounded-lg shadow-lg ring ring-default fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-h-[calc(100dvh-2rem)] sm:max-h-[calc(100dvh-4rem)] overflow-hidden" id="" role="dialog" aria-describedby="reka-dialog-description-v-0-0-1" aria-labelledby="reka-dialog-title-v-0-0-0" data-state="open"> - <!--v-if--> - <div data-slot="header" class="flex items-center gap-1.5 p-4 sm:px-6 min-h-16"> - <div data-slot="wrapper" class=""> - <!--v-if--> - <!--v-if--> - </div><button type="button" aria-label="Close" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-sm gap-1.5 text-default hover:bg-elevated active:bg-elevated focus:outline-none focus-visible:bg-elevated hover:disabled:bg-transparent dark:hover:disabled:bg-transparent hover:aria-disabled:bg-transparent dark:hover:aria-disabled:bg-transparent p-1.5 absolute top-4 end-4"><span class="iconify i-lucide:x shrink-0 size-5" aria-hidden="true" data-slot="leadingIcon"></span> - <!--v-if--> - <!--v-if--> - </button> - </div> - <!--v-if--> - <!--v-if--> - </div> - - - <!--teleport end-->" ❯ ../components/Modal.spec.ts:39:18

Check failure on line 39 in test/components/Modal.spec.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

test/components/Modal.spec.ts > Modal > renders with forceMount correctly

Error: Snapshot `Modal > renders with forceMount correctly 1` mismatched - Expected + Received "<!--v-if--> <!--teleport start--> - - - - <div data-state="open" style="pointer-events: auto;" data-slot="overlay" class="fixed inset-0 data-[state=open]:animate-[fade-in_200ms_ease-out] data-[state=closed]:animate-[fade-out_200ms_ease-in] bg-elevated/75"></div> - <div data-dismissable-layer="" style="pointer-events: auto;" tabindex="-1" data-slot="content" class="bg-default divide-y divide-default flex flex-col focus:outline-none data-[state=open]:animate-[scale-in_200ms_ease-out] data-[state=closed]:animate-[scale-out_200ms_ease-in] w-[calc(100vw-2rem)] max-w-lg rounded-lg shadow-lg ring ring-default fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-h-[calc(100dvh-2rem)] sm:max-h-[calc(100dvh-4rem)] overflow-hidden" id="" role="dialog" aria-describedby="reka-dialog-description-v-1" aria-labelledby="reka-dialog-title-v-0" data-state="open"> - <!--v-if--> - <div data-slot="header" class="flex items-center gap-1.5 p-4 sm:px-6 min-h-16"> - <div data-slot="wrapper" class=""> - <!--v-if--> - <!--v-if--> - </div><button type="button" aria-label="Close" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-sm gap-1.5 text-default hover:bg-elevated active:bg-elevated focus:outline-none focus-visible:bg-elevated hover:disabled:bg-transparent dark:hover:disabled:bg-transparent hover:aria-disabled:bg-transparent dark:hover:aria-disabled:bg-transparent p-1.5 absolute top-4 end-4"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="leadingIcon" class="shrink-0 size-5"></svg> - <!--v-if--> - <!--v-if--> - </button> - </div> - <!--v-if--> - <!--v-if--> - </div> - - - <!--teleport end-->" ❯ test/components/Modal.spec.ts:39:18
})

it('passes accessibility tests', async () => {
Expand Down
27 changes: 27 additions & 0 deletions test/components/__snapshots__/Modal-vue.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,33 @@ exports[`Modal > renders with footer slot correctly 1`] = `



<!--teleport end-->"
`;

exports[`Modal > renders with forceMount correctly 1`] = `
"<!--v-if-->
<!--teleport start-->



<div data-state="open" style="pointer-events: auto;" data-slot="overlay" class="fixed inset-0 data-[state=open]:animate-[fade-in_200ms_ease-out] data-[state=closed]:animate-[fade-out_200ms_ease-in] bg-elevated/75"></div>
<div data-dismissable-layer="" style="pointer-events: auto;" tabindex="-1" data-slot="content" class="bg-default divide-y divide-default flex flex-col focus:outline-none data-[state=open]:animate-[scale-in_200ms_ease-out] data-[state=closed]:animate-[scale-out_200ms_ease-in] w-[calc(100vw-2rem)] max-w-lg rounded-lg shadow-lg ring ring-default fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-h-[calc(100dvh-2rem)] sm:max-h-[calc(100dvh-4rem)] overflow-hidden" id="" role="dialog" aria-describedby="reka-dialog-description-v-1" aria-labelledby="reka-dialog-title-v-0" data-state="open">
<!--v-if-->
<div data-slot="header" class="flex items-center gap-1.5 p-4 sm:px-6 min-h-16">
<div data-slot="wrapper" class="">
<!--v-if-->
<!--v-if-->
</div><button type="button" aria-label="Close" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-sm gap-1.5 text-default hover:bg-elevated active:bg-elevated focus:outline-none focus-visible:bg-elevated hover:disabled:bg-transparent dark:hover:disabled:bg-transparent hover:aria-disabled:bg-transparent dark:hover:aria-disabled:bg-transparent p-1.5 absolute top-4 end-4"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="leadingIcon" class="shrink-0 size-5"></svg>
<!--v-if-->
<!--v-if-->
</button>
</div>
<!--v-if-->
<!--v-if-->
</div>



<!--teleport end-->"
`;

Expand Down
27 changes: 27 additions & 0 deletions test/components/__snapshots__/Modal.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,33 @@ exports[`Modal > renders with footer slot correctly 1`] = `



<!--teleport end-->"
`;

exports[`Modal > renders with forceMount correctly 1`] = `
"<!--v-if-->
<!--teleport start-->



<div data-state="open" style="pointer-events: auto;" data-slot="overlay" class="fixed inset-0 data-[state=open]:animate-[fade-in_200ms_ease-out] data-[state=closed]:animate-[fade-out_200ms_ease-in] bg-elevated/75"></div>
<div data-dismissable-layer="" style="pointer-events: auto;" tabindex="-1" data-slot="content" class="bg-default divide-y divide-default flex flex-col focus:outline-none data-[state=open]:animate-[scale-in_200ms_ease-out] data-[state=closed]:animate-[scale-out_200ms_ease-in] w-[calc(100vw-2rem)] max-w-lg rounded-lg shadow-lg ring ring-default fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-h-[calc(100dvh-2rem)] sm:max-h-[calc(100dvh-4rem)] overflow-hidden" id="" role="dialog" aria-describedby="reka-dialog-description-v-0-0-1" aria-labelledby="reka-dialog-title-v-0-0-0" data-state="open">
<!--v-if-->
<div data-slot="header" class="flex items-center gap-1.5 p-4 sm:px-6 min-h-16">
<div data-slot="wrapper" class="">
<!--v-if-->
<!--v-if-->
</div><button type="button" aria-label="Close" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-sm gap-1.5 text-default hover:bg-elevated active:bg-elevated focus:outline-none focus-visible:bg-elevated hover:disabled:bg-transparent dark:hover:disabled:bg-transparent hover:aria-disabled:bg-transparent dark:hover:aria-disabled:bg-transparent p-1.5 absolute top-4 end-4"><span class="iconify i-lucide:x shrink-0 size-5" aria-hidden="true" data-slot="leadingIcon"></span>
<!--v-if-->
<!--v-if-->
</button>
</div>
<!--v-if-->
<!--v-if-->
</div>



<!--teleport end-->"
`;

Expand Down
Loading