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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ still apply).
the Phosphor set already installed via `presetIcons`) on a `<span class="i-ph:..." aria-hidden="true" />`.
Exception: purely data-driven/generative vector graphics (e.g. `DisplayDonut`'s
progress ring) aren't icons and stay inline `<svg>`.
- Anything that teleports (a reka `*Portal`, a `<Teleport>`) must route its target
through `usePortalTarget(() => props.to)` and expose the `to?: string | HTMLElement`
prop, so the content stays inside an enclosing shadow root. Add the component to
the `cases` list in `test/portal.test.ts` and to the `Utilities/ShadowRoot` story.

## Keep the Storybook Overview up to date

Expand Down
30 changes: 10 additions & 20 deletions packages/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ contrast scan in light + dark after each step.
## Embedding in a shadow root

Mounting the components inside a shadow root — a web component, a devtools dock,
an injected overlay — is supported, but three things do not cross the boundary on
an injected overlay — is supported, but two things do not cross the boundary on
their own:

- **The scheme class goes on an _ancestor_, never on the element you style.** The
Expand All @@ -142,32 +142,22 @@ their own:
wrapper — `display: contents` works, so it costs no layout — and put the surface
utilities on its child. `:host(.dark)` is not an alternative: these are
descendant selectors, and they can't reach across the boundary.
- **Portaled overlays need a target inside the root.** reka-ui teleports
`Select`/`Combobox`/dropdown/menu content to `document.body` by default, i.e.
*outside* the shadow root that holds your stylesheet — so the popup renders
unstyled and positions against a trigger in another tree. Wrap once in reka's
`ConfigProvider` with `teleport-to` pointing at a container inside the root, and
put that container *outside* your scroll container so the popper isn't clipped:

```vue
<ConfigProvider :teleport-to="portalTarget">
<div ref="portalTarget" />
<div class="of-auto"><!-- scrolling content --></div>
</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 `*` /
unscoped selectors precisely so they work in either place. Add
`scrollbar-gutter: stable` on your own scroll container if you want the gutter
reserved.

Portaled content needs no setup. Every component that teleports — the dialogs
(`OverlayModal`, `OverlayDrawer`, `OverlayConfirm`), the poppers (`FormSelect`,
`FormCombobox`, `OverlayDropdown`, `OverlayHoverCard`, `OverlayContextMenu`,
`LayoutMenubar`) and `FeedbackToasts` — detects the enclosing shadow root and
teleports into a container appended to it, which also mirrors the `dark`/`light`
class of the nearest scheme wrapper so dark-mode tokens keep resolving there. Pass
an explicit `to` (a selector or element) to override the target. The
**Utilities/ShadowRoot** story mounts them all inside a shadow root.

## Accessibility

A color-contrast scan (axe-core + Playwright) runs a URL in light **and** dark
Expand Down
14 changes: 9 additions & 5 deletions packages/design/components/Feedback/FeedbackToasts.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<!-- @description a presentational toast stack (`items`, `position`) — the app owns the list and ids via its own `useToast()`. -->
<script lang="ts">
<script setup lang="ts">
import { usePortalTarget } from '../../composables/portalTarget'

export type ToastType = 'info' | 'success' | 'warning' | 'error'

/** A single toast. The **app** owns the list and the ids — this component is presentational. */
Expand All @@ -18,14 +20,14 @@ export interface ToastItem {
/** Optional action button label; pressing it fires the `action` event. */
action?: string
}
</script>

<script setup lang="ts">
withDefaults(
const props = withDefaults(
defineProps<{
/** The toast list — owned and mutated by the app (e.g. a local `ref`). */
items: ToastItem[]
position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'
/** Teleport target for the stack; defaults to the enclosing shadow root, else `document.body`. */
to?: string | HTMLElement
}>(),
{ position: 'bottom-right' },
)
Expand All @@ -37,6 +39,8 @@ const emit = defineEmits<{
action: [id: string | number]
}>()

const portalTo = usePortalTarget(() => props.to)

const POSITION = {
'top-right': 'top-4 right-4',
'bottom-right': 'bottom-4 right-4',
Expand All @@ -53,7 +57,7 @@ const TYPE_CLASS: Record<ToastType, string> = {
</script>

<template>
<Teleport to="body">
<Teleport :to="portalTo ?? 'body'">
<div
class="flex flex-col gap-2 max-w-[calc(100vw-2rem)] w-80 fixed z-toast"
:class="POSITION[position]"
Expand Down
26 changes: 14 additions & 12 deletions packages/design/components/Form/FormCombobox.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!-- @description a searchable, filterable select (reka-ui `Combobox`) over an `options` list. -->
<script setup lang="ts">
import { ComboboxAnchor, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxPortal, ComboboxRoot, ComboboxViewport } from 'reka-ui'
import { usePortalTarget } from '../../composables/portalTarget'

export interface ComboboxOption {
value: string
Expand All @@ -13,21 +14,22 @@ export interface ComboboxOption {
// visible. Route them to the anchor, which *is* the field.
defineOptions({ inheritAttrs: false })

withDefaults(
defineProps<{
/** Selectable options. `label` falls back to `value`; `disabled` blocks selection. */
options: ComboboxOption[]
/** Placeholder shown in the search input while empty. */
placeholder?: string
/** Disable the whole combobox. */
disabled?: boolean
}>(),
{},
)
const props = defineProps<{
/** Selectable options. `label` falls back to `value`; `disabled` blocks selection. */
options: ComboboxOption[]
/** Placeholder shown in the search input while empty. */
placeholder?: string
/** Disable the whole combobox. */
disabled?: boolean
/** Teleport target for the list; defaults to the enclosing shadow root, else `document.body`. */
to?: string | HTMLElement
}>()

/** The selected option's `value`. */
const model = defineModel<string>()

const portalTo = usePortalTarget(() => props.to)

// reka-ui filters items by their `textValue` against the typed query, so we
// surface the visible label as the search text — this keeps filtering correct
// even when the `#option` slot customises rendering.
Expand All @@ -48,7 +50,7 @@ function optionLabel(option: ComboboxOption) {
class="color-base outline-none bg-transparent flex-1 min-w-0 placeholder:op-mute"
/>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxPortal :to="portalTo">
<ComboboxContent
position="popper"
:side-offset="6"
Expand Down
9 changes: 7 additions & 2 deletions packages/design/components/Form/FormSelect.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!-- @description a native-feeling select bound with `v-model`. -->
<script setup lang="ts">
import { SelectContent, SelectIcon, SelectItem, SelectItemIndicator, SelectItemText, SelectPortal, SelectRoot, SelectTrigger, SelectValue, SelectViewport } from 'reka-ui'
import { usePortalTarget } from '../../composables/portalTarget'

export interface SelectOption {
value: string
Expand All @@ -13,13 +14,17 @@ export interface SelectOption {
// trigger — the element a caller means when they style "the select".
defineOptions({ inheritAttrs: false })

defineProps<{
const props = defineProps<{
options: SelectOption[]
placeholder?: string
disabled?: boolean
/** Teleport target for the list; defaults to the enclosing shadow root, else `document.body`. */
to?: string | HTMLElement
}>()

const model = defineModel<string>()

const portalTo = usePortalTarget(() => props.to)
</script>

<template>
Expand All @@ -33,7 +38,7 @@ const model = defineModel<string>()
<span class="i-ph:caret-down" aria-hidden="true" />
</SelectIcon>
</SelectTrigger>
<SelectPortal>
<SelectPortal :to="portalTo">
<SelectContent
position="popper"
:side-offset="6"
Expand Down
11 changes: 9 additions & 2 deletions packages/design/components/Layout/LayoutMenubar.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!-- @description a desktop-style menu bar from a `menus` list of `MenubarMenuEntry`. -->
<script setup lang="ts">
import { MenubarContent, MenubarItem, MenubarMenu, MenubarPortal, MenubarRoot, MenubarSeparator, MenubarTrigger } from 'reka-ui'
import { usePortalTarget } from '../../composables/portalTarget'
import DisplayKbd from '../Display/DisplayKbd.vue'

export interface MenubarItemEntry {
Expand All @@ -19,8 +20,14 @@ export interface MenubarMenuEntry {
items: MenubarItemEntry[]
}

defineProps<{ menus: MenubarMenuEntry[] }>()
const props = defineProps<{
menus: MenubarMenuEntry[]
/** Teleport target for the menus; defaults to the enclosing shadow root, else `document.body`. */
to?: string | HTMLElement
}>()
const emit = defineEmits<{ select: [menu: string, item: string] }>()

const portalTo = usePortalTarget(() => props.to)
</script>

<template>
Expand All @@ -29,7 +36,7 @@ const emit = defineEmits<{ select: [menu: string, item: string] }>()
<MenubarTrigger class="text-sm px-2 py-1 outline-none rounded-md select-none data-[highlighted]:bg-hover data-[state=open]:bg-active focus-visible:ring-2 focus-visible:ring-primary-500/40">
{{ menu.label }}
</MenubarTrigger>
<MenubarPortal>
<MenubarPortal :to="portalTo">
<MenubarContent
:side-offset="6"
align="start"
Expand Down
74 changes: 35 additions & 39 deletions packages/design/components/Overlay/OverlayConfirm.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<!-- @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'

const props = withDefaults(
Expand All @@ -21,45 +20,42 @@ const props = withDefaults(
const emit = defineEmits<{ confirm: [], cancel: [] }>()
const open = defineModel<boolean>('open')

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

<template>
<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>
<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>
</template>
9 changes: 7 additions & 2 deletions packages/design/components/Overlay/OverlayContextMenu.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
<!-- @description a right-click menu. -->
<script setup lang="ts">
import { ContextMenuContent, ContextMenuPortal, ContextMenuRoot, ContextMenuTrigger } from 'reka-ui'
import { usePortalTarget } from '../../composables/portalTarget'

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

const portalTo = usePortalTarget(() => props.to)
</script>

<template>
<ContextMenuRoot>
<ContextMenuTrigger :disabled="disabled" as-child>
<slot name="trigger" />
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuPortal :to="portalTo">
<ContextMenuContent
:collision-padding="8"
class="p-1 outline-none border border-base rounded-lg bg-glass:75 min-w-40 shadow-lg z-dropdown"
Expand Down
Loading
Loading