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()} +