-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/design/components/Overview.mdx b/packages/design/components/Overview.mdx
index 324b806..63d6181 100644
--- a/packages/design/components/Overview.mdx
+++ b/packages/design/components/Overview.mdx
@@ -64,6 +64,7 @@ import * as OverlayHoverCard from './Overlay/OverlayHoverCard.stories'
import * as OverlayModal from './Overlay/OverlayModal.stories'
import * as OverlayTooltip from './Overlay/OverlayTooltip.stories'
import * as ScrollFade from './Utility/ScrollFade.stories'
+import * as ShadowRoot from './Utility/ShadowRoot.stories'
import * as Shimmer from './Utility/Shimmer.stories'
@@ -454,6 +455,10 @@ A composable, token-driven design system for devtools-style Vue apps. Each tile
+### [ShadowRoot](/?path=/docs/utility-shadowroot--docs)
+
+
+
### [Shimmer](/?path=/docs/utility-shimmer--docs)
diff --git a/packages/design/components/Utility/ShadowRoot.stories.ts b/packages/design/components/Utility/ShadowRoot.stories.ts
new file mode 100644
index 0000000..230086a
--- /dev/null
+++ b/packages/design/components/Utility/ShadowRoot.stories.ts
@@ -0,0 +1,145 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+import { createApp, defineComponent, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { useToast } from '../../composables/toast'
+import ActionButton from '../Action/ActionButton.vue'
+import FeedbackToasts from '../Feedback/FeedbackToasts.vue'
+import FormCombobox from '../Form/FormCombobox.vue'
+import FormSelect from '../Form/FormSelect.vue'
+import LayoutMenubar from '../Layout/LayoutMenubar.vue'
+import OverlayConfirm from '../Overlay/OverlayConfirm.vue'
+import OverlayContextMenu from '../Overlay/OverlayContextMenu.vue'
+import OverlayContextMenuItem from '../Overlay/OverlayContextMenuItem.vue'
+import OverlayDrawer from '../Overlay/OverlayDrawer.vue'
+import OverlayDropdown from '../Overlay/OverlayDropdown.vue'
+import OverlayDropdownItem from '../Overlay/OverlayDropdownItem.vue'
+import OverlayDropdownSub from '../Overlay/OverlayDropdownSub.vue'
+import OverlayHoverCard from '../Overlay/OverlayHoverCard.vue'
+import OverlayModal from '../Overlay/OverlayModal.vue'
+
+// Every component that portals content (selects, menus, dialogs, toasts) must
+// keep it inside the shadow root that holds the stylesheet. This mounts them
+// all in one, the way a devtools overlay built with `defineCustomElement` would.
+
+const options = ['TypeScript', 'Vue', 'UnoCSS', 'Vite'].map(value => ({ value }))
+const menus = [
+ { value: 'file', label: 'File', items: [{ value: 'new', label: 'New', shortcut: 'mod+n' }, { value: 'open', label: 'Open…', shortcut: 'mod+o' }] },
+ { value: 'edit', label: 'Edit', items: [{ value: 'undo', label: 'Undo', shortcut: 'mod+z' }, { value: 'redo', label: 'Redo', shortcut: 'mod+shift+z' }] },
+]
+
+const Embedded = defineComponent({
+ components: {
+ ActionButton,
+ FeedbackToasts,
+ FormCombobox,
+ FormSelect,
+ LayoutMenubar,
+ OverlayConfirm,
+ OverlayContextMenu,
+ OverlayContextMenuItem,
+ OverlayDrawer,
+ OverlayDropdown,
+ OverlayDropdownItem,
+ OverlayDropdownSub,
+ OverlayHoverCard,
+ OverlayModal,
+ },
+ setup() {
+ const toast = useToast()
+ return { options, menus, toast, selected: ref('Vue'), searched: ref
() }
+ },
+ template: `
+
toast.info(m + ' › ' + i)" />
+
+
+
+
+
+
+ Dropdown
+ Open
+
+ Copy link
+ Email
+
+ Delete
+
+
+ Hover card
+ Rendered inside the shadow root, so the glass surface and border tokens apply.
+
+
+ Modal
+ Scheme, surface and border tokens all resolve here.
+
+
+ Drawer
+ Slides in over the host page, styled from the root's sheet.
+
+
+ Confirm
+
+
Toast
+
+
+
+ Right-click here
+
+ Copy
+ Cut
+ Delete
+
+
+ `,
+})
+
+// The raw CSS source, not CSSOM `cssText`: Chromium serializes shorthands
+// holding `var()` (the icon `mask`) as empty, which would break every icon.
+async function documentCss(): Promise {
+ const sources = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'), el =>
+ el instanceof HTMLLinkElement ? fetch(el.href).then(r => r.text()) : el.textContent ?? '')
+ return (await Promise.all(sources)).join('\n')
+}
+
+/** Mounts {@link Embedded} in a shadow root, with the document's sheets injected the way `defineCustomElement({ styles })` would. */
+const ShadowHost = defineComponent({
+ props: { dark: Boolean },
+ setup(props) {
+ const host = ref()
+ const app = createApp(Embedded)
+
+ // The scheme wrapper the README asks for: the class on an ancestor, the
+ // surface utilities on its child.
+ const wrapper = document.createElement('div')
+ wrapper.style.display = 'contents'
+ watch(() => props.dark, (dark) => {
+ wrapper.className = dark ? 'dark' : 'light'
+ }, { immediate: true })
+
+ onMounted(async () => {
+ const shadow = host.value!.attachShadow({ mode: 'open' })
+ const style = document.createElement('style')
+ style.textContent = await documentCss()
+ shadow.append(style, wrapper)
+ app.mount(wrapper)
+ })
+ onBeforeUnmount(() => app.unmount())
+ return () => h('div', { ref: host })
+ },
+})
+
+const meta = {
+ title: 'Utilities/ShadowRoot',
+ component: ShadowHost,
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ render: (_, { globals }) => ({
+ components: { ShadowHost },
+ setup: () => ({ dark: globals.theme === 'dark' }),
+ template: ``,
+ }),
+}
diff --git a/packages/design/composables/portalTarget.ts b/packages/design/composables/portalTarget.ts
index 734a2ba..b159f90 100644
--- a/packages/design/composables/portalTarget.ts
+++ b/packages/design/composables/portalTarget.ts
@@ -2,39 +2,62 @@
* 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
+ * outside the shadow root that holds the injected stylesheet — so a popup
* 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.
+ *
+ * The container also mirrors the `dark`/`light` class of the component's
+ * nearest scheme wrapper: `dark:` utilities compile to a descendant selector
+ * (`.dark .x`), and a child of the `ShadowRoot` has no `.dark` ancestor of its
+ * own.
*/
-import type { ComputedRef, MaybeRefOrGetter } from 'vue'
-import { computed, onBeforeUnmount, onMounted, shallowRef, toValue } from 'vue'
+import type { ComputedRef } from 'vue'
+import { computed, getCurrentInstance, onBeforeUnmount, onMounted, shallowRef } from 'vue'
/**
- * Resolve the teleport target for an overlay's portal.
+ * Resolve the teleport target for the calling component's portal.
+ *
+ * Call it in `setup`: the enclosing {@link ShadowRoot} is detected from the
+ * component's own root node once mounted.
*
- * @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.
+ * @returns The target to pass to reka's `*Portal` (or ``) `to` prop.
*/
export function usePortalTarget(
- anchor: MaybeRefOrGetter,
explicit?: () => string | HTMLElement | undefined,
): ComputedRef {
+ const instance = getCurrentInstance()
const container = shallowRef()
+ let observer: MutationObserver | undefined
onMounted(() => {
- const root = toValue(anchor)?.getRootNode()
- if (root instanceof ShadowRoot) {
- container.value = document.createElement('div')
- root.append(container.value)
+ // For fragment/teleport roots `$el` is Vue's placeholder node, which still
+ // sits in the component's own tree — enough to find the root.
+ const el: Node | undefined = instance?.proxy?.$el
+ const root = el?.getRootNode()
+ if (!(root instanceof ShadowRoot))
+ return
+
+ const target = document.createElement('div')
+ root.append(target)
+ container.value = target
+
+ const scheme = (el instanceof Element ? el : el?.parentElement)?.closest('.dark, .light')
+ if (scheme) {
+ const sync = (): void => {
+ target.className = scheme.classList.contains('dark') ? 'dark' : 'light'
+ }
+ sync()
+ observer = new MutationObserver(sync)
+ observer.observe(scheme, { attributeFilter: ['class'] })
}
})
onBeforeUnmount(() => {
+ observer?.disconnect()
container.value?.remove()
container.value = undefined
})
diff --git a/packages/design/test/overlay-portal.test.ts b/packages/design/test/overlay-portal.test.ts
deleted file mode 100644
index 3a9b809..0000000
--- a/packages/design/test/overlay-portal.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-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()
- })
-})
diff --git a/packages/design/test/portal.test.ts b/packages/design/test/portal.test.ts
new file mode 100644
index 0000000..bbaea37
--- /dev/null
+++ b/packages/design/test/portal.test.ts
@@ -0,0 +1,200 @@
+import type { VueWrapper } from '@vue/test-utils'
+import type { Component } from 'vue'
+import { mount as mountComponent } from '@vue/test-utils'
+import { afterEach, describe, expect, it } from 'vitest'
+import { h, nextTick } from 'vue'
+import Toasts from '../components/Feedback/FeedbackToasts.vue'
+import Combobox from '../components/Form/FormCombobox.vue'
+import Select from '../components/Form/FormSelect.vue'
+import Menubar from '../components/Layout/LayoutMenubar.vue'
+import Confirm from '../components/Overlay/OverlayConfirm.vue'
+import ContextMenu from '../components/Overlay/OverlayContextMenu.vue'
+import Drawer from '../components/Overlay/OverlayDrawer.vue'
+import Dropdown from '../components/Overlay/OverlayDropdown.vue'
+import DropdownItem from '../components/Overlay/OverlayDropdownItem.vue'
+import DropdownSub from '../components/Overlay/OverlayDropdownSub.vue'
+import HoverCard from '../components/Overlay/OverlayHoverCard.vue'
+import Modal from '../components/Overlay/OverlayModal.vue'
+
+const settle = () => new Promise(resolve => setTimeout(resolve, 10))
+
+const options = [{ value: 'a' }, { value: 'b' }]
+const trigger = () => h('button', { 'data-trigger': '' }, 'open')
+const marker = () => h('span', { 'data-marker': '' }, 'content')
+
+interface Case {
+ name: string
+ component: Component
+ props?: Record
+ slots?: Record ReturnType>
+ /** Portaled-content selector. */
+ marker: string
+ /** Open the popup from its trigger inside `root`. */
+ open?: (root: ParentNode) => Promise
+}
+
+async function fire(root: ParentNode, selector: string, event: Event) {
+ root.querySelector(selector)!.dispatchEvent(event)
+ await nextTick()
+ await settle()
+}
+
+const cases: Case[] = [
+ { name: 'modal', component: Modal, props: { open: true }, marker: '[data-af-modal]' },
+ { name: 'drawer', component: Drawer, props: { open: true }, marker: '[data-af-drawer]' },
+ { name: 'confirm', component: Confirm, props: { open: true }, marker: '[data-af-modal]' },
+ { name: 'toasts', component: Toasts, props: { items: [{ id: 1, message: 'hi' }] }, marker: '[role="region"]' },
+ {
+ name: 'select',
+ component: Select,
+ props: { options },
+ marker: '[role="listbox"]',
+ open: root => fire(root, '[role="combobox"]', new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })),
+ },
+ {
+ name: 'combobox',
+ component: Combobox,
+ props: { options },
+ marker: '[role="listbox"]',
+ open: root => fire(root, 'input', new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })),
+ },
+ {
+ name: 'menubar',
+ component: Menubar,
+ props: { menus: [{ value: 'file', label: 'File', items: [{ value: 'new', label: 'New' }] }] },
+ marker: '[role="menu"]',
+ open: root => fire(root, '[role="menuitem"]', new PointerEvent('pointerdown', { button: 0, bubbles: true })),
+ },
+ {
+ name: 'dropdown',
+ component: Dropdown,
+ slots: { trigger, default: marker },
+ marker: '[data-marker]',
+ open: root => fire(root, '[data-trigger]', new MouseEvent('click', { button: 0, bubbles: true })),
+ },
+ {
+ name: 'dropdown submenu',
+ component: Dropdown,
+ slots: {
+ trigger,
+ default: () => h(DropdownSub, { label: 'More' }, { default: () => h(DropdownItem, { label: 'Deep' }, { default: marker }) }),
+ },
+ marker: '[data-marker]',
+ open: async (root) => {
+ await fire(root, '[data-trigger]', new MouseEvent('click', { button: 0, bubbles: true }))
+ await fire(root, '[aria-haspopup="menu"][role="menuitem"]', new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
+ },
+ },
+ {
+ name: 'hover card',
+ component: HoverCard,
+ props: { openDelay: 0 },
+ slots: { trigger, default: marker },
+ marker: '[data-marker]',
+ open: root => fire(root, '[data-trigger]', new PointerEvent('pointerenter', { pointerType: 'mouse' })),
+ },
+ {
+ name: 'context menu',
+ component: ContextMenu,
+ slots: { trigger, default: marker },
+ marker: '[data-marker]',
+ open: root => fire(root, '[data-trigger]', new MouseEvent('contextmenu', { bubbles: true, cancelable: true })),
+ },
+]
+
+const mounted: VueWrapper[] = []
+const mount: typeof mountComponent = (...args) => {
+ const wrapper = mountComponent(...args)
+ mounted.push(wrapper)
+ return wrapper
+}
+
+function shadowMountPoint() {
+ const host = document.createElement('div')
+ document.body.append(host)
+ const shadow = host.attachShadow({ mode: 'open' })
+ const mountPoint = document.createElement('div')
+ shadow.append(mountPoint)
+ return { shadow, mountPoint }
+}
+
+afterEach(() => {
+ mounted.splice(0).forEach(wrapper => wrapper.unmount())
+ document.body.innerHTML = ''
+})
+
+/** The container `usePortalTarget` appended to the shadow root. */
+function portalContainer(shadow: ShadowRoot) {
+ return Array.from(shadow.children).find(child => child.querySelector('[data-af-modal]'))!
+}
+
+describe('portals default to document.body', () => {
+ it.each(cases)('$name teleports its content to the body', async ({ component, props, slots, marker, open }) => {
+ mount(component, { attachTo: document.body, props, slots })
+ await nextTick()
+ await open?.(document.body)
+ await nextTick()
+ expect(document.body.querySelector(marker)).toBeTruthy()
+ })
+})
+
+describe('portals stay inside a shadow root', () => {
+ it.each(cases)('$name teleports its content into the enclosing shadow root, not the body', async ({ component, props, slots, marker, open }) => {
+ const { shadow, mountPoint } = shadowMountPoint()
+
+ mount(component, { attachTo: mountPoint, props, slots })
+ await nextTick()
+ await open?.(shadow)
+ 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 { shadow, mountPoint } = shadowMountPoint()
+
+ const target = document.createElement('div')
+ 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()
+ })
+})
+
+describe('the portal container mirrors the scheme wrapper', () => {
+ it('copies `dark`/`light` from the nearest scheme ancestor and follows toggles', async () => {
+ const { shadow, mountPoint } = shadowMountPoint()
+ mountPoint.className = 'dark'
+
+ mount(Modal, { attachTo: mountPoint, props: { open: true } })
+ await nextTick()
+ await nextTick()
+
+ const container = portalContainer(shadow)
+ expect(container.classList.contains('dark')).toBe(true)
+
+ mountPoint.className = 'light'
+ await settle()
+ expect(container.classList.contains('dark')).toBe(false)
+ expect(container.classList.contains('light')).toBe(true)
+ })
+
+ it('leaves the container bare when there is no scheme wrapper', async () => {
+ const { shadow, mountPoint } = shadowMountPoint()
+
+ mount(Modal, { attachTo: mountPoint, props: { open: true } })
+ await nextTick()
+ await nextTick()
+
+ expect(portalContainer(shadow).className).toBe('')
+ })
+})
diff --git a/packages/design/test/scroll-fade.test.ts b/packages/design/test/scroll-fade.test.ts
index 7d4dbb0..d7bdcb4 100644
--- a/packages/design/test/scroll-fade.test.ts
+++ b/packages/design/test/scroll-fade.test.ts
@@ -60,7 +60,7 @@ describe('shimmer (ported from shadcn/ui)', () => {
const css = await generate('shimmer', true)
expect(css).toContain('@keyframes tw-shimmer')
expect(css).toContain('background-clip:text')
- expect(css).toContain('html.dark')
+ expect(css).toContain(':where(.dark) .shimmer')
expect(css).toContain('prefers-reduced-motion:reduce')
})
})
diff --git a/packages/design/unocss/shimmer.ts b/packages/design/unocss/shimmer.ts
index 27b4809..7b16a3d 100644
--- a/packages/design/unocss/shimmer.ts
+++ b/packages/design/unocss/shimmer.ts
@@ -32,7 +32,7 @@ export const shimmerRules: Rule[] = [
'animation': 'tw-shimmer var(--shimmer-duration, 2s) linear infinite',
},
`@keyframes tw-shimmer{from{background-position:100% 0}to{background-position:0 0}}`,
- `:where(html.dark) .shimmer{--_highlight:var(--shimmer-color,oklch(from currentColor max(0.8,calc(l + 0.4)) c h / calc(alpha + 0.4)))}`,
+ `:where(.dark) .shimmer{--_highlight:var(--shimmer-color,oklch(from currentColor max(0.8,calc(l + 0.4)) c h / calc(alpha + 0.4)))}`,
`.shimmer:where([dir="rtl"],[dir="rtl"] *){animation-direction:reverse}`,
`@media (prefers-reduced-motion:reduce){.shimmer{animation:none;background-image:none;-webkit-text-fill-color:currentColor}}`,
]],