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
5 changes: 5 additions & 0 deletions packages/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ their own:
</ConfigProvider>
```

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 `*` /
Expand Down
79 changes: 44 additions & 35 deletions packages/design/components/Overlay/OverlayConfirm.vue
Original file line number Diff line number Diff line change
@@ -1,56 +1,65 @@
<!-- @description an AlertDialog for a confirm/cancel decision, `variant="danger"` for destructive actions. -->
<script setup lang="ts">
import { AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogOverlay, AlertDialogPortal, AlertDialogRoot, AlertDialogTitle, AlertDialogTrigger } from 'reka-ui'
import { ref } from 'vue'
import { usePortalTarget } from '../../composables/portalTarget'

withDefaults(
const props = withDefaults(
defineProps<{
title?: string
description?: string
confirmLabel?: string
cancelLabel?: string
/** `danger` paints the confirm button red for destructive actions. */
variant?: 'default' | 'danger'
/** Teleport target for the portal; defaults to the enclosing shadow root, else `document.body`. */
to?: string | HTMLElement
}>(),
{ confirmLabel: 'Confirm', cancelLabel: 'Cancel', variant: 'default' },
)

const emit = defineEmits<{ confirm: [], cancel: [] }>()
const open = defineModel<boolean>('open')

const anchor = ref<HTMLElement>()
const portalTo = usePortalTarget(anchor, () => props.to)
</script>

<template>
<AlertDialogRoot v-model:open="open">
<AlertDialogTrigger v-if="$slots.trigger" as-child>
<slot name="trigger" />
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="bg-[#ddd]/40 inset-0 fixed z-modal-backdrop backdrop-blur-sm dark:bg-black/40" data-af-animate />
<!-- Centered by the wrapper, whose padding is also the minimum margin — see OverlayModal. -->
<div class="p-4 flex pointer-events-none items-center inset-0 justify-center fixed z-modal-content sm:p-6">
<AlertDialogContent
class="p-4 outline-none border border-base rounded-xl bg-base max-h-full max-w-sm w-full pointer-events-auto shadow-2xl overflow-auto"
data-af-modal
>
<AlertDialogTitle v-if="title" class="color-base font-medium">
{{ title }}
</AlertDialogTitle>
<AlertDialogDescription v-if="description" class="text-sm mt-1 op-fade">
{{ description }}
</AlertDialogDescription>
<div class="mt-4 flex gap-2 justify-end">
<AlertDialogCancel class="btn-action" @click="emit('cancel')">
{{ cancelLabel }}
</AlertDialogCancel>
<AlertDialogAction
class="btn-primary"
:class="variant === 'danger' ? 'bg-red-500! hover:bg-red-600!' : ''"
@click="emit('confirm')"
>
{{ confirmLabel }}
</AlertDialogAction>
</div>
</AlertDialogContent>
</div>
</AlertDialogPortal>
</AlertDialogRoot>
<div ref="anchor" style="display: contents">
<AlertDialogRoot v-model:open="open">
<AlertDialogTrigger v-if="$slots.trigger" as-child>
<slot name="trigger" />
</AlertDialogTrigger>
<AlertDialogPortal :to="portalTo">
<AlertDialogOverlay class="bg-[#ddd]/40 inset-0 fixed z-modal-backdrop backdrop-blur-sm dark:bg-black/40" data-af-animate />
<!-- Centered by the wrapper, whose padding is also the minimum margin — see OverlayModal. -->
<div class="p-4 flex pointer-events-none items-center inset-0 justify-center fixed z-modal-content sm:p-6">
<AlertDialogContent
class="p-4 outline-none border border-base rounded-xl bg-base max-h-full max-w-sm w-full pointer-events-auto shadow-2xl overflow-auto"
data-af-modal
>
<AlertDialogTitle v-if="title" class="color-base font-medium">
{{ title }}
</AlertDialogTitle>
<AlertDialogDescription v-if="description" class="text-sm mt-1 op-fade">
{{ description }}
</AlertDialogDescription>
<div class="mt-4 flex gap-2 justify-end">
<AlertDialogCancel class="btn-action" @click="emit('cancel')">
{{ cancelLabel }}
</AlertDialogCancel>
<AlertDialogAction
class="btn-primary"
:class="variant === 'danger' ? 'bg-red-500! hover:bg-red-600!' : ''"
@click="emit('confirm')"
>
{{ confirmLabel }}
</AlertDialogAction>
</div>
</AlertDialogContent>
</div>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</template>
78 changes: 43 additions & 35 deletions packages/design/components/Overlay/OverlayDrawer.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<!-- @description a slide-in panel from any `side` (left/right/top/bottom), sized by `width`. -->
<script setup lang="ts">
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from 'reka-ui'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { usePortalTarget } from '../../composables/portalTarget'

const props = withDefaults(
defineProps<{
Expand All @@ -14,12 +15,17 @@ const props = withDefaults(
* full viewport width.
*/
width?: string | number
/** Teleport target for the portal; defaults to the enclosing shadow root, else `document.body`. */
to?: string | HTMLElement
}>(),
{ side: 'right' },
)

const open = defineModel<boolean>('open')

const anchor = ref<HTMLElement>()
const portalTo = usePortalTarget(anchor, () => props.to)

const SIDE_CLASS = {
right: 'right-0 top-0 h-full w-80 max-w-[90vw] border-l',
left: 'left-0 top-0 h-full w-80 max-w-[90vw] border-r',
Expand All @@ -36,38 +42,40 @@ const style = computed(() => {
</script>

<template>
<DialogRoot v-model:open="open">
<DialogTrigger v-if="$slots.trigger" as-child>
<slot name="trigger" />
</DialogTrigger>
<DialogPortal>
<DialogOverlay class="bg-[#ddd]/40 inset-0 fixed z-drawer-backdrop backdrop-blur-sm dark:bg-black/40" data-af-animate />
<DialogContent
class="outline-none border-base bg-base flex flex-col shadow-2xl fixed z-drawer-content"
:class="SIDE_CLASS[side]"
:style="style"
data-af-drawer
:data-side="side"
>
<header class="px-4 py-3 border-b border-base flex shrink-0 gap-4 items-center justify-between">
<DialogTitle v-if="title" class="color-base font-medium">
{{ title }}
</DialogTitle>
<DialogDescription v-if="$slots.description" class="sr-only">
<slot name="description" />
</DialogDescription>
<slot name="header" />
<DialogClose class="btn-icon shrink-0 h-7 w-7" aria-label="Close">
<span class="i-ph:x" aria-hidden="true" />
</DialogClose>
</header>
<div class="p-4 flex-1 overflow-auto">
<slot />
</div>
<footer v-if="$slots.footer" class="px-4 py-3 border-t border-base flex shrink-0 gap-2 items-center justify-end">
<slot name="footer" />
</footer>
</DialogContent>
</DialogPortal>
</DialogRoot>
<div ref="anchor" style="display: contents">
<DialogRoot v-model:open="open">
<DialogTrigger v-if="$slots.trigger" as-child>
<slot name="trigger" />
</DialogTrigger>
<DialogPortal :to="portalTo">
<DialogOverlay class="bg-[#ddd]/40 inset-0 fixed z-drawer-backdrop backdrop-blur-sm dark:bg-black/40" data-af-animate />
<DialogContent
class="outline-none border-base bg-base flex flex-col shadow-2xl fixed z-drawer-content"
:class="SIDE_CLASS[side]"
:style="style"
data-af-drawer
:data-side="side"
>
<header class="px-4 py-3 border-b border-base flex shrink-0 gap-4 items-center justify-between">
<DialogTitle v-if="title" class="color-base font-medium">
{{ title }}
</DialogTitle>
<DialogDescription v-if="$slots.description" class="sr-only">
<slot name="description" />
</DialogDescription>
<slot name="header" />
<DialogClose class="btn-icon shrink-0 h-7 w-7" aria-label="Close">
<span class="i-ph:x" aria-hidden="true" />
</DialogClose>
</header>
<div class="p-4 flex-1 overflow-auto">
<slot />
</div>
<footer v-if="$slots.footer" class="px-4 py-3 border-t border-base flex shrink-0 gap-2 items-center justify-end">
<slot name="footer" />
</footer>
</DialogContent>
</DialogPortal>
</DialogRoot>
</div>
</template>
95 changes: 52 additions & 43 deletions packages/design/components/Overlay/OverlayModal.vue
Original file line number Diff line number Diff line change
@@ -1,57 +1,66 @@
<!-- @description a centered dialog (`title`, `description`, `#trigger` slot). -->
<script setup lang="ts">
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from 'reka-ui'
import { ref } from 'vue'
import { usePortalTarget } from '../../composables/portalTarget'

defineProps<{
const props = defineProps<{
title?: string
description?: string
/** Teleport target for the portal; defaults to the enclosing shadow root, else `document.body`. */
to?: string | HTMLElement
}>()

const open = defineModel<boolean>('open')

const anchor = ref<HTMLElement>()
const portalTo = usePortalTarget(anchor, () => props.to)
</script>

<template>
<DialogRoot v-model:open="open">
<DialogTrigger v-if="$slots.trigger" as-child>
<slot name="trigger" />
</DialogTrigger>
<DialogPortal>
<DialogOverlay class="bg-[#ddd]/40 inset-0 fixed z-modal-backdrop backdrop-blur-sm dark:bg-black/40" data-af-animate />
<!--
Centering lives on this wrapper rather than on the content itself: a
`left-1/2 -translate-x-1/2` content box double-shifts under Wind4 (whose
translate utilities use the native `translate` property, which stacks on
top of the animation's `transform`). The padding here doubles as the
modal's minimum margin, and makes `max-h-full` mean "viewport minus that
margin", so tall content scrolls in the body instead of overflowing.
-->
<div class="p-4 flex pointer-events-none items-center inset-0 justify-center fixed z-modal-content">
<DialogContent
class="outline-none border border-base rounded-lg bg-base flex flex-col max-h-full max-w-lg w-full pointer-events-auto shadow-xl overflow-hidden"
data-af-modal
>
<header v-if="title || description || $slots.header" class="px-3 py-2 border-b border-base flex shrink-0 gap-2 items-start justify-between">
<div class="min-w-0">
<DialogTitle v-if="title" class="color-base font-medium">
{{ title }}
</DialogTitle>
<DialogDescription v-if="description" class="text-sm op-fade">
{{ description }}
</DialogDescription>
<slot name="header" />
<div ref="anchor" style="display: contents">
<DialogRoot v-model:open="open">
<DialogTrigger v-if="$slots.trigger" as-child>
<slot name="trigger" />
</DialogTrigger>
<DialogPortal :to="portalTo">
<DialogOverlay class="bg-[#ddd]/40 inset-0 fixed z-modal-backdrop backdrop-blur-sm dark:bg-black/40" data-af-animate />
<!--
Centering lives on this wrapper rather than on the content itself: a
`left-1/2 -translate-x-1/2` content box double-shifts under Wind4 (whose
translate utilities use the native `translate` property, which stacks on
top of the animation's `transform`). The padding here doubles as the
modal's minimum margin, and makes `max-h-full` mean "viewport minus that
margin", so tall content scrolls in the body instead of overflowing.
-->
<div class="p-4 flex pointer-events-none items-center inset-0 justify-center fixed z-modal-content">
<DialogContent
class="outline-none border border-base rounded-lg bg-base flex flex-col max-h-full max-w-lg w-full pointer-events-auto shadow-xl overflow-hidden"
data-af-modal
>
<header v-if="title || description || $slots.header" class="px-3 py-2 border-b border-base flex shrink-0 gap-2 items-start justify-between">
<div class="min-w-0">
<DialogTitle v-if="title" class="color-base font-medium">
{{ title }}
</DialogTitle>
<DialogDescription v-if="description" class="text-sm op-fade">
{{ description }}
</DialogDescription>
<slot name="header" />
</div>
<DialogClose class="btn-icon shrink-0 h-7 w-7" aria-label="Close">
<span class="i-ph:x" aria-hidden="true" />
</DialogClose>
</header>
<div class="p-3 flex-1 overflow-auto">
<slot />
</div>
<DialogClose class="btn-icon shrink-0 h-7 w-7" aria-label="Close">
<span class="i-ph:x" aria-hidden="true" />
</DialogClose>
</header>
<div class="p-3 flex-1 overflow-auto">
<slot />
</div>
<footer v-if="$slots.footer" class="px-2 py-2 border-t border-base flex shrink-0 gap-2 justify-end">
<slot name="footer" />
</footer>
</DialogContent>
</div>
</DialogPortal>
</DialogRoot>
<footer v-if="$slots.footer" class="px-2 py-2 border-t border-base flex shrink-0 gap-2 justify-end">
<slot name="footer" />
</footer>
</DialogContent>
</div>
</DialogPortal>
</DialogRoot>
</div>
</template>
1 change: 1 addition & 0 deletions packages/design/composables/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './colorScheme'
export * from './portalTarget'
export * from './toast'
43 changes: 43 additions & 0 deletions packages/design/composables/portalTarget.ts
Original file line number Diff line number Diff line change
@@ -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<Element | null | undefined>,
explicit?: () => string | HTMLElement | undefined,
): ComputedRef<string | HTMLElement | undefined> {
const container = shallowRef<HTMLElement>()

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)
}
Loading
Loading