Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Modal**, **Slideover**, **Drawer**, **Popover** — with `portal={false}`, a Select, DatePicker, DropdownMenu, Tooltip or other floating layer opened inside the container no longer renders behind it when an ancestor sets a `z-index` above 50. Nested layers now render in the container's stacking context, above its panel. Portalled containers are unchanged. ([#217](https://github.com/ndlabdev/sv5ui/issues/217))

## [2.7.0] - 2026-09-09

### Added
Expand Down
20 changes: 14 additions & 6 deletions src/lib/components/Drawer/Drawer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { Drawer } from 'vaul-svelte'
import { drawerVariants, drawerDefaults } from './drawer.variants.js'
import { getComponentConfig } from '../../config.js'
import PortalScope from '../../internal/PortalScope.svelte'

const config = getComponentConfig('drawer', drawerDefaults)

Expand Down Expand Up @@ -48,6 +49,8 @@
...rest
}: Props = $props()

let contentEl = $state<HTMLElement | null>(null)

const hasTitle = $derived(!!title || !!titleSlot)
const hasDescription = $derived(!!description || !!descriptionSlot)
const hasHeading = $derived(hasTitle || hasDescription)
Expand Down Expand Up @@ -167,12 +170,17 @@
<Drawer.Overlay class={classes.overlay} />
{/if}

<Drawer.Content class={[classes.content, !children ? className : undefined]}>
{#if showHandle}
<Drawer.Handle class={classes.handle} />
{/if}
{@render drawerInner()}
</Drawer.Content>
<PortalScope active={!portal && (open || !!contentEl)}>
<Drawer.Content
bind:ref={contentEl}
class={[classes.content, !children ? className : undefined]}
>
{#if showHandle}
<Drawer.Handle class={classes.handle} />
{/if}
{@render drawerInner()}
</Drawer.Content>
</PortalScope>
{/snippet}

{#snippet drawerBody()}
Expand Down
42 changes: 42 additions & 0 deletions src/lib/components/Drawer/Drawer.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../../../routes/layout.css'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-svelte'
import { page } from 'vitest/browser'
Expand Down Expand Up @@ -743,4 +744,45 @@ describe('Drawer', () => {
})
})
})

describe('nested portals', () => {
const getHost = () => document.querySelector('[data-portal-host]') as HTMLElement | null
const getLayer = () => document.querySelector('[data-vaul-drawer]') as HTMLElement | null
const zIndex = (el: Element) => Number(getComputedStyle(el).zIndex)

it('adds no portal host while closed', () => {
render(Drawer, { portal: false, title: 'Test' })
expect(getHost()).toBeNull()
})

it('adds no portal host when portalled', async () => {
render(Drawer, { open: true, title: 'Test', modal: false })
await vi.waitFor(() => expect(getLayer()).not.toBeNull())
expect(getHost()).toBeNull()
})

it('mounts nested layers above the inline content without becoming the last child', async () => {
render(Drawer, { open: true, portal: false, title: 'Test', modal: false })
await vi.waitFor(() => expect(getHost()).not.toBeNull())
const host = getHost()!
const layer = getLayer()!
expect(
host.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(host.parentElement!.lastElementChild).not.toBe(host)
expect(zIndex(host)).toBeGreaterThan(zIndex(layer))
})

it('removes the portal host once closed', async () => {
const { rerender } = render(Drawer, {
open: true,
portal: false,
title: 'Test',
modal: false
})
await vi.waitFor(() => expect(getHost()).not.toBeNull())
await rerender({ open: false })
await vi.waitFor(() => expect(getHost()).toBeNull())
})
})
})
27 changes: 18 additions & 9 deletions src/lib/components/Modal/Modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { modalVariants, modalDefaults } from './modal.variants.js'
import { getComponentConfig } from '../../config.js'
import Button from '../Button/Button.svelte'
import PortalScope from '../../internal/PortalScope.svelte'

const config = getComponentConfig('modal', modalDefaults)

Expand Down Expand Up @@ -47,6 +48,8 @@
closeSlot
}: Props = $props()

let contentEl = $state<HTMLElement | null>(null)

const resolvedSize = $derived(fullscreen ? 'full' : size)
const resolvedTransition = $derived(
transition === false ? 'none' : transition === true ? 'scale' : transition
Expand Down Expand Up @@ -117,7 +120,11 @@
{/snippet}

{#snippet modalContentInner()}
<Dialog.Content {...contentProps} class={[classes.content, !children ? className : undefined]}>
<Dialog.Content
{...contentProps}
bind:ref={contentEl}
class={[classes.content, !children ? className : undefined]}
>
{#if contentSlot}
{#if hasHeading}
<div class="sr-only">
Expand Down Expand Up @@ -189,16 +196,18 @@
{/snippet}

{#snippet modalPortalContent()}
{#if scrollable}
<Dialog.Overlay class={classes.overlay}>
{#if showOverlay && !scrollable}
<Dialog.Overlay class={classes.overlay} />
{/if}
<PortalScope active={!portal && (open || !!contentEl)}>
{#if scrollable}
<Dialog.Overlay class={classes.overlay}>
{@render modalContentInner()}
</Dialog.Overlay>
{:else}
{@render modalContentInner()}
</Dialog.Overlay>
{:else}
{#if showOverlay}
<Dialog.Overlay class={classes.overlay} />
{/if}
{@render modalContentInner()}
{/if}
</PortalScope>
{/snippet}

<Dialog.Root bind:open onOpenChange={handleOpenChange} {onOpenChangeComplete}>
Expand Down
48 changes: 48 additions & 0 deletions src/lib/components/Modal/Modal.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../../../routes/layout.css'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-svelte'
import { page } from 'vitest/browser'
Expand Down Expand Up @@ -563,4 +564,51 @@ describe('Modal', () => {
})
})
})

describe('nested portals', () => {
const getHost = () => document.querySelector('[data-portal-host]') as HTMLElement | null
const getLayer = () => document.querySelector('[data-dialog-content]') as HTMLElement | null
const zIndex = (el: Element) => Number(getComputedStyle(el).zIndex)

it('adds no portal host while closed', () => {
render(Modal, { portal: false, title: 'Test' })
expect(getHost()).toBeNull()
})

it('adds no portal host when portalled', async () => {
render(Modal, { open: true, title: 'Test' })
await vi.waitFor(() => expect(getLayer()).not.toBeNull())
expect(getHost()).toBeNull()
})

it('mounts nested layers above the inline content without becoming the last child', async () => {
render(Modal, { open: true, portal: false, title: 'Test' })
await vi.waitFor(() => expect(getHost()).not.toBeNull())
const host = getHost()!
const layer = getLayer()!
expect(
host.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(host.parentElement!.lastElementChild).not.toBe(host)
expect(zIndex(host)).toBeGreaterThan(zIndex(layer))
})

it('removes the portal host once closed', async () => {
const { rerender } = render(Modal, { open: true, portal: false, title: 'Test' })
await vi.waitFor(() => expect(getHost()).not.toBeNull())
await rerender({ open: false })
await vi.waitFor(() => expect(getHost()).toBeNull())
})

it('mounts nested layers above a scrollable overlay', async () => {
render(Modal, { open: true, portal: false, scrollable: true, title: 'Test' })
await vi.waitFor(() => expect(getHost()).not.toBeNull())
const host = getHost()!
const overlay = document.querySelector('[data-dialog-overlay]') as HTMLElement
expect(
host.compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(zIndex(host)).toBeGreaterThan(zIndex(overlay))
})
})
})
3 changes: 2 additions & 1 deletion src/lib/components/Popover/Popover.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { Popover } from 'bits-ui'
import { popoverVariants, popoverDefaults } from './popover.variants.js'
import { getComponentConfig } from '../../config.js'
import PortalScope from '../../internal/PortalScope.svelte'

const config = getComponentConfig('popover', popoverDefaults)

Expand Down Expand Up @@ -147,6 +148,6 @@
{@render popoverContentEl()}
</Popover.Portal>
{:else}
{@render popoverContentEl()}
<PortalScope active={open || !!ref}>{@render popoverContentEl()}</PortalScope>
{/if}
</Popover.Root>
38 changes: 38 additions & 0 deletions src/lib/components/Popover/Popover.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../../../routes/layout.css'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-svelte'
import Popover from './Popover.svelte'
Expand Down Expand Up @@ -315,4 +316,41 @@ describe('Popover', () => {
})
})
})

describe('nested portals', () => {
const getHost = () => document.querySelector('[data-portal-host]') as HTMLElement | null
const getLayer = () =>
document.querySelector('[data-popover-content]')?.parentElement as HTMLElement | null
const zIndex = (el: Element) => Number(getComputedStyle(el).zIndex)

it('adds no portal host while closed', () => {
render(Popover, { portal: false })
expect(getHost()).toBeNull()
})

it('adds no portal host when portalled', async () => {
render(Popover, { open: true })
await vi.waitFor(() => expect(getLayer()).not.toBeNull())
expect(getHost()).toBeNull()
})

it('mounts nested layers above the inline content without becoming the last child', async () => {
render(Popover, { open: true, portal: false })
await vi.waitFor(() => expect(getHost()).not.toBeNull())
const host = getHost()!
const layer = getLayer()!
expect(
host.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(host.parentElement!.lastElementChild).not.toBe(host)
expect(zIndex(host)).toBeGreaterThan(zIndex(layer))
})

it('removes the portal host once closed', async () => {
const { rerender } = render(Popover, { open: true, portal: false })
await vi.waitFor(() => expect(getHost()).not.toBeNull())
await rerender({ open: false })
await vi.waitFor(() => expect(getHost()).toBeNull())
})
})
})
13 changes: 11 additions & 2 deletions src/lib/components/Slideover/Slideover.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { getComponentConfig } from '../../config.js'
import Button from '../Button/Button.svelte'
import ScrollArea from '../ScrollArea/ScrollArea.svelte'
import PortalScope from '../../internal/PortalScope.svelte'

const config = getComponentConfig('slideover', slideoverDefaults)

Expand Down Expand Up @@ -49,6 +50,8 @@
closeSlot
}: Props = $props()

let contentEl = $state<HTMLElement | null>(null)

const showClose = $derived(!!closeProp)
const closeProps = $derived(typeof closeProp === 'object' ? closeProp : {})

Expand Down Expand Up @@ -119,7 +122,11 @@
{/snippet}

{#snippet slideoverContentInner()}
<Dialog.Content {...contentProps} class={[classes.content, !children ? className : undefined]}>
<Dialog.Content
{...contentProps}
bind:ref={contentEl}
class={[classes.content, !children ? className : undefined]}
>
{#if contentSlot}
{#if hasHeading}
<div class="sr-only">
Expand Down Expand Up @@ -196,7 +203,9 @@
{#if showOverlay}
<Dialog.Overlay class={classes.overlay} />
{/if}
{@render slideoverContentInner()}
<PortalScope active={!portal && (open || !!contentEl)}>
{@render slideoverContentInner()}
</PortalScope>
{/snippet}

<Dialog.Root bind:open onOpenChange={handleOpenChange} {onOpenChangeComplete}>
Expand Down
37 changes: 37 additions & 0 deletions src/lib/components/Slideover/Slideover.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../../../routes/layout.css'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-svelte'
import { createRawSnippet } from 'svelte'
Expand Down Expand Up @@ -669,4 +670,40 @@ describe('Slideover', () => {
})
})
})

describe('nested portals', () => {
const getHost = () => document.querySelector('[data-portal-host]') as HTMLElement | null
const getLayer = () => document.querySelector('[data-dialog-content]') as HTMLElement | null
const zIndex = (el: Element) => Number(getComputedStyle(el).zIndex)

it('adds no portal host while closed', () => {
render(Slideover, { portal: false, title: 'Test' })
expect(getHost()).toBeNull()
})

it('adds no portal host when portalled', async () => {
render(Slideover, { open: true, title: 'Test' })
await vi.waitFor(() => expect(getLayer()).not.toBeNull())
expect(getHost()).toBeNull()
})

it('mounts nested layers above the inline content without becoming the last child', async () => {
render(Slideover, { open: true, portal: false, title: 'Test' })
await vi.waitFor(() => expect(getHost()).not.toBeNull())
const host = getHost()!
const layer = getLayer()!
expect(
host.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(host.parentElement!.lastElementChild).not.toBe(host)
expect(zIndex(host)).toBeGreaterThan(zIndex(layer))
})

it('removes the portal host once closed', async () => {
const { rerender } = render(Slideover, { open: true, portal: false, title: 'Test' })
await vi.waitFor(() => expect(getHost()).not.toBeNull())
await rerender({ open: false })
await vi.waitFor(() => expect(getHost()).toBeNull())
})
})
})
15 changes: 15 additions & 0 deletions src/lib/internal/PortalScope.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts">
import { BitsConfig } from 'bits-ui'
import type { Snippet } from 'svelte'

let { active, children }: { active: boolean; children: Snippet } = $props()

let hostEl = $state<HTMLElement | null>(null)
</script>

{#if active}
<div bind:this={hostEl} class="fixed top-0 left-0 z-51" data-portal-host></div>
{/if}
<BitsConfig defaultPortalTo={active ? (hostEl ?? undefined) : undefined}>
{@render children()}
</BitsConfig>