diff --git a/packages/design/README.md b/packages/design/README.md index 985da58..bc7791d 100644 --- a/packages/design/README.md +++ b/packages/design/README.md @@ -156,6 +156,11 @@ their own: ``` + The dialog family (`OverlayModal`, `OverlayDrawer`, `OverlayConfirm`) needs no + such setup: it detects the enclosing shadow root and teleports into it on its + own, so it works out of the box under `defineCustomElement`. Pass an explicit + `to` (a selector or element) to override the target. + - **Global CSS can't pierce the boundary — inline the styles.** `@antfu/design/styles/*` (scrollbar, reka-ui animations, vue-afloat, …) must be part of the sheet you inject into the root, alongside the UnoCSS output. They're written with `*` / diff --git a/packages/design/components/Overlay/OverlayConfirm.vue b/packages/design/components/Overlay/OverlayConfirm.vue index 52be24f..8a17382 100644 --- a/packages/design/components/Overlay/OverlayConfirm.vue +++ b/packages/design/components/Overlay/OverlayConfirm.vue @@ -1,8 +1,10 @@ diff --git a/packages/design/components/Overlay/OverlayDrawer.vue b/packages/design/components/Overlay/OverlayDrawer.vue index 2765c82..8540175 100644 --- a/packages/design/components/Overlay/OverlayDrawer.vue +++ b/packages/design/components/Overlay/OverlayDrawer.vue @@ -1,7 +1,8 @@ diff --git a/packages/design/components/Overlay/OverlayModal.vue b/packages/design/components/Overlay/OverlayModal.vue index b19e17c..45a6bba 100644 --- a/packages/design/components/Overlay/OverlayModal.vue +++ b/packages/design/components/Overlay/OverlayModal.vue @@ -1,57 +1,66 @@ diff --git a/packages/design/composables/index.ts b/packages/design/composables/index.ts index ddb943a..b0d60fa 100644 --- a/packages/design/composables/index.ts +++ b/packages/design/composables/index.ts @@ -1,2 +1,3 @@ export * from './colorScheme' +export * from './portalTarget' export * from './toast' diff --git a/packages/design/composables/portalTarget.ts b/packages/design/composables/portalTarget.ts new file mode 100644 index 0000000..734a2ba --- /dev/null +++ b/packages/design/composables/portalTarget.ts @@ -0,0 +1,43 @@ +/** + * Teleport target for portaled overlays inside a shadow root. + * + * reka-ui teleports overlay content to `document.body` by default, which is + * outside the shadow root that holds the injected stylesheet — so a modal + * mounted via `defineCustomElement` renders unstyled and outside the tree it + * belongs to. This resolves a target that keeps the overlay inside the root: + * an explicit `to` wins, otherwise a container appended to the enclosing + * `ShadowRoot` (when there is one), otherwise `undefined` so reka falls back to + * its own `ConfigProvider`/`body` resolution unchanged. + */ +import type { ComputedRef, MaybeRefOrGetter } from 'vue' +import { computed, onBeforeUnmount, onMounted, shallowRef, toValue } from 'vue' + +/** + * Resolve the teleport target for an overlay's portal. + * + * @param anchor - An element rendered in the component's normal tree (not the + * portaled content), used to detect the enclosing {@link ShadowRoot}. + * @param explicit - Getter for a caller-provided `to` override. + * @returns The target to pass to reka's `*Portal` `to` prop. + */ +export function usePortalTarget( + anchor: MaybeRefOrGetter, + explicit?: () => string | HTMLElement | undefined, +): ComputedRef { + const container = shallowRef() + + onMounted(() => { + const root = toValue(anchor)?.getRootNode() + if (root instanceof ShadowRoot) { + container.value = document.createElement('div') + root.append(container.value) + } + }) + + onBeforeUnmount(() => { + container.value?.remove() + container.value = undefined + }) + + return computed(() => explicit?.() ?? container.value) +} diff --git a/packages/design/test/overlay-portal.test.ts b/packages/design/test/overlay-portal.test.ts new file mode 100644 index 0000000..3a9b809 --- /dev/null +++ b/packages/design/test/overlay-portal.test.ts @@ -0,0 +1,66 @@ +import type { Component } from 'vue' +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it } from 'vitest' +import { nextTick } from 'vue' +import Confirm from '../components/Overlay/OverlayConfirm.vue' +import Drawer from '../components/Overlay/OverlayDrawer.vue' +import Modal from '../components/Overlay/OverlayModal.vue' + +const dialogs: [name: string, component: Component, marker: string][] = [ + ['modal', Modal, '[data-af-modal]'], + ['drawer', Drawer, '[data-af-drawer]'], + ['confirm', Confirm, '[data-af-modal]'], +] + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('dialog portals default to document.body', () => { + it.each(dialogs)('%s teleports its content to the body', async (_name, component, marker) => { + mount(component, { props: { open: true } }) + await nextTick() + await nextTick() + expect(document.body.querySelector(marker)).toBeTruthy() + }) +}) + +describe('dialog portals stay inside a shadow root', () => { + it.each(dialogs)('%s teleports its content into the enclosing shadow root, not the body', async (_name, component, marker) => { + const host = document.createElement('div') + document.body.append(host) + const shadow = host.attachShadow({ mode: 'open' }) + const mountPoint = document.createElement('div') + shadow.append(mountPoint) + + mount(component, { attachTo: mountPoint, props: { open: true } }) + await nextTick() + await nextTick() + + expect(shadow.querySelector(marker)).toBeTruthy() + // The whole point: it must not escape to the light-DOM body, where the + // shadow root's stylesheet can't reach it. + expect(document.body.querySelector(marker)).toBeNull() + }) +}) + +describe('an explicit `to` wins over auto-detection', () => { + it('teleports into the given container', async () => { + const host = document.createElement('div') + document.body.append(host) + const shadow = host.attachShadow({ mode: 'open' }) + const mountPoint = document.createElement('div') + shadow.append(mountPoint) + + const target = document.createElement('div') + target.id = 'target' + document.body.append(target) + + mount(Modal, { attachTo: mountPoint, props: { open: true, to: target } }) + await nextTick() + await nextTick() + + expect(target.querySelector('[data-af-modal]')).toBeTruthy() + expect(shadow.querySelector('[data-af-modal]')).toBeNull() + }) +})