refactor: rewrite the Dialog root family without reka-ui

Root/Trigger/Overlay/Close/Title/Description now share a small
provide/inject context instead of reka-ui's Dialog primitives.
DialogContent/DialogScrollContent follow in the next commit since they
need the focus-trap and dismissable-layer composables.
This commit is contained in:
Matthew McPeak 2026-07-13 18:14:52 -04:00
parent 07b44f6de4
commit 3d8bdaa903
8 changed files with 85 additions and 65 deletions

View file

@ -1,19 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui" import { provide, useId } from 'vue'
import { DialogRoot, useForwardPropsEmits } from "reka-ui" import { useVModel } from '@vueuse/core'
import { DialogContextKey } from './context'
const props = defineProps<DialogRootProps>() const props = defineProps<{ open?: boolean, defaultOpen?: boolean }>()
const emits = defineEmits<DialogRootEmits>() const emit = defineEmits<{ 'update:open': [value: boolean] }>()
const forwarded = useForwardPropsEmits(props, emits) const open = useVModel(props, 'open', emit, {
passive: true,
defaultValue: props.defaultOpen ?? false,
})
provide(DialogContextKey, {
open,
titleId: `dialog-title-${useId()}`,
descriptionId: `dialog-description-${useId()}`,
})
</script> </script>
<template> <template>
<DialogRoot <slot />
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template> </template>

View file

@ -1,15 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogCloseProps } from "reka-ui" import { inject } from 'vue'
import { DialogClose } from "reka-ui" import { Primitive } from '@/lib/primitive'
import { DialogContextKey } from './context'
const props = defineProps<DialogCloseProps>() const props = defineProps<{ asChild?: boolean }>()
const context = inject(DialogContextKey)!
</script> </script>
<template> <template>
<DialogClose <Primitive
as="button"
:as-child="props.asChild"
type="button"
data-slot="dialog-close" data-slot="dialog-close"
v-bind="props" @click="context.open.value = false"
> >
<slot /> <slot />
</DialogClose> </Primitive>
</template> </template>

View file

@ -1,23 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui" import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes } from "vue" import { inject } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { cn } from '@/lib/utils'
import { DialogDescription, useForwardProps } from "reka-ui" import { DialogContextKey } from './context'
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<DialogDescription <p
:id="context.descriptionId"
data-slot="dialog-description" data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)" :class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
> >
<slot /> <slot />
</DialogDescription> </p>
</template> </template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { DialogClose } from "reka-ui" import DialogClose from "./DialogClose.vue"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'

View file

@ -1,21 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui" import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes } from "vue" import { inject } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { cn } from '@/lib/utils'
import { DialogOverlay } from "reka-ui" import { DialogContextKey } from './context'
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
<DialogOverlay <Transition
data-slot="dialog-overlay" enter-active-class="animate-in fade-in-0 duration-100"
v-bind="delegatedProps" leave-active-class="animate-out fade-out-0 duration-100"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
> >
<slot /> <div
</DialogOverlay> v-if="context.open.value"
data-slot="dialog-overlay"
:class="cn('bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
/>
</Transition>
</template> </template>

View file

@ -1,23 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogTitleProps } from "reka-ui" import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes } from "vue" import { inject } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { cn } from '@/lib/utils'
import { DialogTitle, useForwardProps } from "reka-ui" import { DialogContextKey } from './context'
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<DialogTitle <h2
:id="context.titleId"
data-slot="dialog-title" data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-base leading-none font-medium font-serif', props.class)" :class="cn('text-base leading-none font-medium font-serif', props.class)"
> >
<slot /> <slot />
</DialogTitle> </h2>
</template> </template>

View file

@ -1,15 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui" import { inject } from 'vue'
import { DialogTrigger } from "reka-ui" import { Primitive } from '@/lib/primitive'
import { DialogContextKey } from './context'
const props = defineProps<DialogTriggerProps>() const props = defineProps<{ asChild?: boolean }>()
const context = inject(DialogContextKey)!
</script> </script>
<template> <template>
<DialogTrigger <Primitive
as="button"
:as-child="props.asChild"
type="button"
data-slot="dialog-trigger" data-slot="dialog-trigger"
v-bind="props" aria-haspopup="dialog"
:aria-expanded="context.open.value"
@click="context.open.value = true"
> >
<slot /> <slot />
</DialogTrigger> </Primitive>
</template> </template>

View file

@ -0,0 +1,9 @@
import type { InjectionKey, Ref } from 'vue'
export interface DialogContext {
open: Ref<boolean>
titleId: string
descriptionId: string
}
export const DialogContextKey: InjectionKey<DialogContext> = Symbol('DialogContext')