269 lines
8.4 KiB
Vue
269 lines
8.4 KiB
Vue
<template>
|
|
<TransitionRoot as="template" :show="open">
|
|
<Dialog as="div" class="relative z-40" @close="onClose">
|
|
<TransitionChild
|
|
as="template"
|
|
enter="ease-out duration-300"
|
|
enter-from="opacity-0"
|
|
enter-to="opacity-100"
|
|
leave="ease-in duration-400"
|
|
leave-from="opacity-100"
|
|
leave-to="opacity-0"
|
|
>
|
|
<div
|
|
class="fixed top-0 left-0 w-full h-full bg-black/70 dark:bg-neutral-900/70 transition-opacity"
|
|
/>
|
|
</TransitionChild>
|
|
<div class="fixed top-0 left-0 z-10 h-screen !h-[100dvh] w-screen">
|
|
<div
|
|
class="flex md:justify-center items-end md:items-center h-full w-full md:p-6"
|
|
>
|
|
<TransitionChild
|
|
as="template"
|
|
enter="ease-out duration-5000"
|
|
enter-from="md:opacity-0 translate-y-[100%] md:translate-y-4"
|
|
enter-to="md:opacity-100 translate-y-0"
|
|
leave="ease-in duration-5000"
|
|
leave-from="md:opacity-100 translate-y-0"
|
|
leave-to="md:opacity-0 translate-y-[100%] md:translate-y-4"
|
|
@after-leave="$emit('fully-closed')"
|
|
>
|
|
<DialogPanel
|
|
:class="[
|
|
'dialog-panel transform rounded-t-lg md:rounded-xl text-foreground overflow-hidden transition-all bg-foundation text-left shadow-xl flex flex-col md:h-auto',
|
|
fullscreen ? 'md:h-full' : 'md:max-h-[90vh]',
|
|
widthClasses
|
|
]"
|
|
:as="isForm ? 'form' : 'div'"
|
|
@submit.prevent="onFormSubmit"
|
|
>
|
|
<div
|
|
v-if="hasTitle"
|
|
:class="scrolledFromTop && 'relative z-20 shadow-lg'"
|
|
>
|
|
<div
|
|
class="flex items-center justify-start rounded-t-lg shrink-0 min-h-[2rem] sm:min-h-[4rem] p-6 truncate text-lg sm:text-2xl font-bold"
|
|
>
|
|
<div class="flex items-center pr-12">
|
|
<ChevronLeftIcon
|
|
v-if="showBackButton"
|
|
class="w-5 h-5 -ml-1 mr-3"
|
|
@click="$emit('back')"
|
|
/>
|
|
<div class="w-full truncate">
|
|
{{ title }}
|
|
<slot name="header" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!--
|
|
Due to how forms work, if there's no other submit button, on form submission the first button
|
|
will be clicked. This is a workaround to prevent the close button from being that first button.
|
|
https://stackoverflow.com/a/4763911/3194577
|
|
-->
|
|
<button class="hidden" type="button" />
|
|
|
|
<button
|
|
v-if="!hideCloser"
|
|
type="button"
|
|
class="absolute z-20 bg-foundation hover:bg-foundation-page transition rounded-full p-1.5 shadow border top-5 right-5 border-outline-3"
|
|
@click="open = false"
|
|
>
|
|
<XMarkIcon class="h-4 w-4 md:w-5 md:h-5" />
|
|
</button>
|
|
<div
|
|
ref="slotContainer"
|
|
class="flex-1 simple-scrollbar overflow-y-auto text-sm sm:text-base"
|
|
:class="
|
|
hasTitle
|
|
? `px-6 pb-4 ${fullscreen && 'md:p-0'}`
|
|
: !fullscreen && 'p-6'
|
|
"
|
|
@scroll="onScroll"
|
|
>
|
|
<slot>Put your content here!</slot>
|
|
</div>
|
|
<div
|
|
v-if="hasButtons"
|
|
class="relative z-50 flex p-6 gap-3 shrink-0 bg-foundation"
|
|
:class="{
|
|
'shadow-t': !scrolledToBottom,
|
|
[buttonsWrapperClasses || '']: true
|
|
}"
|
|
>
|
|
<template v-if="buttons">
|
|
<FormButton
|
|
v-for="(button, index) in buttons"
|
|
:key="button.id || index"
|
|
v-bind="button.props || {}"
|
|
:disabled="button.props?.disabled || button.disabled"
|
|
:submit="button.props?.submit || button.submit"
|
|
@click="($event) => button.onClick?.($event)"
|
|
>
|
|
{{ button.text }}
|
|
</FormButton>
|
|
</template>
|
|
<template v-else>
|
|
<slot name="buttons" />
|
|
</template>
|
|
</div>
|
|
</DialogPanel>
|
|
</TransitionChild>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</TransitionRoot>
|
|
</template>
|
|
<script setup lang="ts">
|
|
import { Dialog, DialogPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
|
import { FormButton, type LayoutDialogButton } from '~~/src/lib'
|
|
import { XMarkIcon, ChevronLeftIcon } from '@heroicons/vue/24/outline'
|
|
import { useResizeObserver, type ResizeObserverCallback } from '@vueuse/core'
|
|
import { computed, ref, useSlots, watch, onUnmounted } from 'vue'
|
|
import { throttle } from 'lodash'
|
|
import { isClient } from '@vueuse/core'
|
|
|
|
type MaxWidthValue = 'sm' | 'md' | 'lg' | 'xl'
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:open', v: boolean): void
|
|
(e: 'fully-closed'): void
|
|
(e: 'back'): void
|
|
}>()
|
|
|
|
const props = defineProps<{
|
|
open: boolean
|
|
maxWidth?: MaxWidthValue
|
|
fullscreen?: boolean
|
|
hideCloser?: boolean
|
|
showBackButton?: boolean
|
|
/**
|
|
* Prevent modal from closing when the user clicks outside of the modal or presses Esc
|
|
*/
|
|
preventCloseOnClickOutside?: boolean
|
|
title?: string
|
|
buttons?: Array<LayoutDialogButton>
|
|
/**
|
|
* Extra classes to apply to the button container.
|
|
*/
|
|
buttonsWrapperClasses?: string
|
|
/**
|
|
* If set, the modal will be wrapped in a form element and the `onSubmit` callback will be invoked when the user submits the form
|
|
*/
|
|
onSubmit?: (e: SubmitEvent) => void
|
|
}>()
|
|
|
|
const slots = useSlots()
|
|
|
|
const scrolledFromTop = ref(false)
|
|
const scrolledToBottom = ref(true)
|
|
const slotContainer = ref<HTMLElement | null>(null)
|
|
|
|
useResizeObserver(
|
|
slotContainer,
|
|
throttle<ResizeObserverCallback>(() => {
|
|
// Triggering onScroll on size change too so that we don't get stuck with shadows
|
|
// even tho the new content is not scrollable
|
|
onScroll({ target: slotContainer.value })
|
|
}, 60)
|
|
)
|
|
|
|
const isForm = computed(() => !!props.onSubmit)
|
|
const hasButtons = computed(() => props.buttons || slots.buttons)
|
|
const hasTitle = computed(() => !!props.title || !!slots.header)
|
|
|
|
const open = computed({
|
|
get: () => props.open,
|
|
set: (newVal) => emit('update:open', newVal)
|
|
})
|
|
|
|
const maxWidthWeight = computed(() => {
|
|
switch (props.maxWidth) {
|
|
case 'sm':
|
|
return 0
|
|
case 'md':
|
|
return 1
|
|
case 'lg':
|
|
return 2
|
|
case 'xl':
|
|
return 3
|
|
default:
|
|
return 10000
|
|
}
|
|
})
|
|
|
|
const widthClasses = computed(() => {
|
|
const classParts: string[] = ['w-full', 'sm:w-full']
|
|
|
|
if (!props.fullscreen) {
|
|
classParts.push('md:max-w-2xl')
|
|
|
|
if (maxWidthWeight.value >= 2) {
|
|
classParts.push('lg:max-w-4xl')
|
|
}
|
|
if (maxWidthWeight.value >= 3) {
|
|
classParts.push('xl:max-w-6xl')
|
|
}
|
|
if (maxWidthWeight.value >= 4) {
|
|
classParts.push('2xl:max-w-7xl')
|
|
}
|
|
}
|
|
|
|
return classParts.join(' ')
|
|
})
|
|
|
|
const onClose = () => {
|
|
if (props.preventCloseOnClickOutside) return
|
|
open.value = false
|
|
}
|
|
|
|
const onFormSubmit = (e: SubmitEvent) => {
|
|
props.onSubmit?.(e)
|
|
}
|
|
|
|
const onScroll = throttle((e: { target: EventTarget | null }) => {
|
|
if (!e.target) return
|
|
|
|
const target = e.target as HTMLElement
|
|
const { scrollTop, offsetHeight, scrollHeight } = target
|
|
scrolledFromTop.value = scrollTop > 0
|
|
scrolledToBottom.value = scrollTop + offsetHeight >= scrollHeight
|
|
}, 60)
|
|
|
|
// Toggle 'dialog-open' class on <html> to prevent scroll jumping and disable background scroll.
|
|
// This maintains user scroll position when Headless UI dialogs are activated.
|
|
watch(open, (newValue) => {
|
|
if (isClient) {
|
|
const html = document.documentElement
|
|
if (newValue) {
|
|
html.classList.add('dialog-open')
|
|
} else {
|
|
html.classList.remove('dialog-open')
|
|
}
|
|
}
|
|
})
|
|
|
|
// Clean up when the component unmounts
|
|
onUnmounted(() => {
|
|
if (isClient) {
|
|
document.documentElement.classList.remove('dialog-open')
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
html.dialog-open {
|
|
overflow: visible !important;
|
|
}
|
|
html.dialog-open body {
|
|
overflow: hidden !important;
|
|
}
|
|
/* Workaround because in Tailwind vh gets added after dvh */
|
|
.dialog-panel {
|
|
height: 98vh;
|
|
height: 98dvh;
|
|
}
|
|
</style>
|