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"> <script setup lang="ts">
import { XIcon } from '@lucide/vue'; import { XIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import { inject, ref, watch } from 'vue'
import type { HTMLAttributes } from "vue" import { useScrollLock } from '@vueuse/core'
import { reactiveOmit } from "@vueuse/core" import { cn } from '@/lib/utils'
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button' 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({ defineOptions({ inheritAttrs: false })
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 }>(), { const isLocked = useScrollLock(document.body)
showCloseButton: true, watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>
<DialogPortal> <Teleport to="body">
<DialogOverlay /> <DialogOverlay />
<DialogContent <Transition
data-slot="dialog-content" enter-active-class="animate-in fade-in-0 zoom-in-95 duration-100"
v-bind="{ ...$attrs, ...forwarded }" leave-active-class="animate-out fade-out-0 zoom-out-95 duration-100"
: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)"
> >
<slot /> <div
v-if="context.open.value"
<DialogClose ref="contentRef"
v-if="showCloseButton" data-slot="dialog-content"
data-slot="dialog-close" role="dialog"
as-child 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)"
> >
<Button variant="secondary" class="absolute top-2 right-2 size-7 bg-transparent hover:bg-black/10" size="icon"> <slot />
<XIcon class="size-3.5" />
<span class="sr-only">Close</span> <DialogClose v-if="showCloseButton" as-child>
</Button> <Button variant="secondary" class="absolute top-2 right-2 size-7 bg-transparent hover:bg-black/10" size="icon">
</DialogClose> <XIcon class="size-3.5" />
</DialogContent> <span class="sr-only">Close</span>
</DialogPortal> </Button>
</DialogClose>
</div>
</Transition>
</Teleport>
</template> </template>

View file

@ -1,60 +1,68 @@
<script setup lang="ts"> <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" defineOptions({ inheritAttrs: false })
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({ const props = defineProps<{ class?: HTMLAttributes['class'] }>()
inheritAttrs: false,
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 isLocked = useScrollLock(document.body)
const emits = defineEmits<DialogContentEmits>() watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>
<DialogPortal> <Teleport to="body">
<DialogOverlay <Transition
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" enter-active-class="animate-in fade-in-0 duration-100"
leave-active-class="animate-out fade-out-0 duration-100"
> >
<DialogContent <div
:class=" v-if="context.open.value"
cn( class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80"
'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',
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 /> <div
ref="contentRef"
<DialogClose data-slot="dialog-content"
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary" 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,
)"
> >
<XIcon class="w-4 h-4" /> <slot />
<span class="sr-only">Close</span>
</DialogClose> <DialogClose class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary">
</DialogContent> <XIcon class="w-4 h-4" />
</DialogOverlay> <span class="sr-only">Close</span>
</DialogPortal> </DialogClose>
</div>
</div>
</Transition>
</Teleport>
</template> </template>