From 01190f0f783a5f382d3c35011b310de8d26439e8 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Thu, 17 Sep 2026 19:15:31 +0700 Subject: [PATCH] fix(portal): keep nested floating layers above inline containers Modal, Slideover, Drawer and Popover rendered with portal={false} hid every floating layer opened inside them (Select, DatePicker, DropdownMenu, Tooltip, a nested Modal, ...) whenever an ancestor set a z-index above 50. The layers were portalled to at z-50 while the inline panel belonged to the higher stacking context. While such a container is open, nested layers now mount into a host placed right before its panel, in the same stacking context and one level above it. The host is removed once the panel has unmounted, so the closed DOM is unchanged, and portalled containers are not affected. Closes #217 --- CHANGELOG.md | 4 ++ src/lib/components/Drawer/Drawer.svelte | 20 +++++--- .../components/Drawer/Drawer.svelte.spec.ts | 42 ++++++++++++++++ src/lib/components/Modal/Modal.svelte | 27 +++++++---- src/lib/components/Modal/Modal.svelte.spec.ts | 48 +++++++++++++++++++ src/lib/components/Popover/Popover.svelte | 3 +- .../components/Popover/Popover.svelte.spec.ts | 38 +++++++++++++++ src/lib/components/Slideover/Slideover.svelte | 13 ++++- .../Slideover/Slideover.svelte.spec.ts | 37 ++++++++++++++ src/lib/internal/PortalScope.svelte | 15 ++++++ 10 files changed, 229 insertions(+), 18 deletions(-) create mode 100644 src/lib/internal/PortalScope.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index 5344d0e..fac98d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/components/Drawer/Drawer.svelte b/src/lib/components/Drawer/Drawer.svelte index 5875211..db79c68 100644 --- a/src/lib/components/Drawer/Drawer.svelte +++ b/src/lib/components/Drawer/Drawer.svelte @@ -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) @@ -48,6 +49,8 @@ ...rest }: Props = $props() + let contentEl = $state(null) + const hasTitle = $derived(!!title || !!titleSlot) const hasDescription = $derived(!!description || !!descriptionSlot) const hasHeading = $derived(hasTitle || hasDescription) @@ -167,12 +170,17 @@ {/if} - - {#if showHandle} - - {/if} - {@render drawerInner()} - + + + {#if showHandle} + + {/if} + {@render drawerInner()} + + {/snippet} {#snippet drawerBody()} diff --git a/src/lib/components/Drawer/Drawer.svelte.spec.ts b/src/lib/components/Drawer/Drawer.svelte.spec.ts index 8dfef1e..b3ffc69 100644 --- a/src/lib/components/Drawer/Drawer.svelte.spec.ts +++ b/src/lib/components/Drawer/Drawer.svelte.spec.ts @@ -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' @@ -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()) + }) + }) }) diff --git a/src/lib/components/Modal/Modal.svelte b/src/lib/components/Modal/Modal.svelte index a15f5b3..d738301 100644 --- a/src/lib/components/Modal/Modal.svelte +++ b/src/lib/components/Modal/Modal.svelte @@ -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) @@ -47,6 +48,8 @@ closeSlot }: Props = $props() + let contentEl = $state(null) + const resolvedSize = $derived(fullscreen ? 'full' : size) const resolvedTransition = $derived( transition === false ? 'none' : transition === true ? 'scale' : transition @@ -117,7 +120,11 @@ {/snippet} {#snippet modalContentInner()} - + {#if contentSlot} {#if hasHeading}
@@ -189,16 +196,18 @@ {/snippet} {#snippet modalPortalContent()} - {#if scrollable} - + {#if showOverlay && !scrollable} + + {/if} + + {#if scrollable} + + {@render modalContentInner()} + + {:else} {@render modalContentInner()} - - {:else} - {#if showOverlay} - {/if} - {@render modalContentInner()} - {/if} + {/snippet} diff --git a/src/lib/components/Modal/Modal.svelte.spec.ts b/src/lib/components/Modal/Modal.svelte.spec.ts index 7807e8d..586da34 100644 --- a/src/lib/components/Modal/Modal.svelte.spec.ts +++ b/src/lib/components/Modal/Modal.svelte.spec.ts @@ -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' @@ -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)) + }) + }) }) diff --git a/src/lib/components/Popover/Popover.svelte b/src/lib/components/Popover/Popover.svelte index 3273e5c..bdbe708 100644 --- a/src/lib/components/Popover/Popover.svelte +++ b/src/lib/components/Popover/Popover.svelte @@ -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) @@ -147,6 +148,6 @@ {@render popoverContentEl()} {:else} - {@render popoverContentEl()} + {@render popoverContentEl()} {/if} diff --git a/src/lib/components/Popover/Popover.svelte.spec.ts b/src/lib/components/Popover/Popover.svelte.spec.ts index 5508f81..998ce86 100644 --- a/src/lib/components/Popover/Popover.svelte.spec.ts +++ b/src/lib/components/Popover/Popover.svelte.spec.ts @@ -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' @@ -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()) + }) + }) }) diff --git a/src/lib/components/Slideover/Slideover.svelte b/src/lib/components/Slideover/Slideover.svelte index 5ac1af9..e42496f 100644 --- a/src/lib/components/Slideover/Slideover.svelte +++ b/src/lib/components/Slideover/Slideover.svelte @@ -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) @@ -49,6 +50,8 @@ closeSlot }: Props = $props() + let contentEl = $state(null) + const showClose = $derived(!!closeProp) const closeProps = $derived(typeof closeProp === 'object' ? closeProp : {}) @@ -119,7 +122,11 @@ {/snippet} {#snippet slideoverContentInner()} - + {#if contentSlot} {#if hasHeading}
@@ -196,7 +203,9 @@ {#if showOverlay} {/if} - {@render slideoverContentInner()} + + {@render slideoverContentInner()} + {/snippet} diff --git a/src/lib/components/Slideover/Slideover.svelte.spec.ts b/src/lib/components/Slideover/Slideover.svelte.spec.ts index b748a9a..44c74a1 100644 --- a/src/lib/components/Slideover/Slideover.svelte.spec.ts +++ b/src/lib/components/Slideover/Slideover.svelte.spec.ts @@ -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' @@ -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()) + }) + }) }) diff --git a/src/lib/internal/PortalScope.svelte b/src/lib/internal/PortalScope.svelte new file mode 100644 index 0000000..edc4cff --- /dev/null +++ b/src/lib/internal/PortalScope.svelte @@ -0,0 +1,15 @@ + + +{#if active} +
+{/if} + + {@render children()} +