refactor: rewrite DialogContent/DialogScrollContent without reka-ui

Uses Teleport + Transition + useFocusTrap + useDismissableLayer +
useScrollLock. The Dialog family no longer imports reka-ui at all.
This commit is contained in:
Matthew McPeak 2026-07-13 18:15:30 -04:00
parent 3d8bdaa903
commit e7d96cc3ba
2 changed files with 107 additions and 89 deletions

View file

@ -1,53 +1,63 @@
<script setup lang="ts">
import { XIcon } from '@lucide/vue';
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { XIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { inject, ref, watch } from 'vue'
import { useScrollLock } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import DialogOverlay from "./DialogOverlay.vue"
import { useFocusTrap } from '@/lib/use-focus-trap'
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
import { DialogContextKey } from './context'
import DialogOverlay from './DialogOverlay.vue'
import DialogClose from './DialogClose.vue'
defineOptions({
inheritAttrs: false,
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{ class?: HTMLAttributes['class'], showCloseButton?: boolean }>(),
{ showCloseButton: true },
)
const context = inject(DialogContextKey)!
const contentRef = ref<HTMLElement | null>(null)
useFocusTrap(contentRef, context.open)
useDismissableLayer(contentRef, context.open, {
onDismiss: () => { context.open.value = false },
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const isLocked = useScrollLock(document.body)
watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
</script>
<template>
<DialogPortal>
<Teleport to="body">
<DialogOverlay />
<DialogContent
<Transition
enter-active-class="animate-in fade-in-0 zoom-in-95 duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-95 duration-100"
>
<div
v-if="context.open.value"
ref="contentRef"
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 border border-border grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm shadow-lg duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
role="dialog"
aria-modal="true"
:aria-labelledby="context.titleId"
:aria-describedby="context.descriptionId"
tabindex="-1"
v-bind="$attrs"
:class="cn('bg-popover text-popover-foreground border border-border grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm shadow-lg sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
as-child
>
<DialogClose v-if="showCloseButton" as-child>
<Button variant="secondary" class="absolute top-2 right-2 size-7 bg-transparent hover:bg-black/10" size="icon">
<XIcon class="size-3.5" />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</DialogContent>
</DialogPortal>
</div>
</Transition>
</Teleport>
</template>

View file

@ -1,60 +1,68 @@
<script setup lang="ts">
import { XIcon } from '@lucide/vue';
import { XIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { inject, ref, watch } from 'vue'
import { useScrollLock } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useFocusTrap } from '@/lib/use-focus-trap'
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
import { DialogContextKey } from './context'
import DialogClose from './DialogClose.vue'
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({ inheritAttrs: false })
defineOptions({
inheritAttrs: false,
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
const contentRef = ref<HTMLElement | null>(null)
useFocusTrap(contentRef, context.open)
useDismissableLayer(contentRef, context.open, {
onDismiss: () => { context.open.value = false },
onPointerDownOutside: (event) => {
const target = event.target as HTMLElement
if (event.offsetX > target.clientWidth || event.offsetY > target.clientHeight) {
event.preventDefault()
}
},
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const isLocked = useScrollLock(document.body)
watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
<Teleport to="body">
<Transition
enter-active-class="animate-in fade-in-0 duration-100"
leave-active-class="animate-out fade-out-0 duration-100"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
<div
v-if="context.open.value"
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80"
>
<div
ref="contentRef"
data-slot="dialog-content"
role="dialog"
aria-modal="true"
:aria-labelledby="context.titleId"
:aria-describedby="context.descriptionId"
tabindex="-1"
v-bind="$attrs"
:class="cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
)"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<DialogClose class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary">
<XIcon class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</div>
</div>
</Transition>
</Teleport>
</template>