147 lines
4.2 KiB
Vue
147 lines
4.2 KiB
Vue
<script setup>
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
|
|
const props = defineProps({
|
|
align: {
|
|
type: String,
|
|
default: 'right',
|
|
},
|
|
width: {
|
|
type: String,
|
|
default: '48',
|
|
},
|
|
contentClasses: {
|
|
type: Array,
|
|
default: () => ['py-1', 'bg-white'],
|
|
},
|
|
closeOnContentClick: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
});
|
|
|
|
let open = ref(false);
|
|
const triggerEl = ref(null);
|
|
const panelEl = ref(null);
|
|
const panelStyle = ref({ top: '0px', left: '0px' });
|
|
|
|
const closeOnEscape = (e) => {
|
|
if (open.value && e.key === 'Escape') {
|
|
open.value = false;
|
|
}
|
|
};
|
|
|
|
const updatePosition = () => {
|
|
const t = triggerEl.value;
|
|
const p = panelEl.value;
|
|
if (!t || !p) return;
|
|
const rect = t.getBoundingClientRect();
|
|
// Ensure we have updated width
|
|
const pw = p.offsetWidth || 0;
|
|
const ph = p.offsetHeight || 0;
|
|
const margin = 8; // small spacing from trigger
|
|
let left = rect.left;
|
|
if (props.align === 'right') {
|
|
left = rect.right - pw;
|
|
} else if (props.align === 'left') {
|
|
left = rect.left;
|
|
}
|
|
// Clamp within viewport
|
|
const maxLeft = Math.max(0, window.innerWidth - pw - margin);
|
|
left = Math.min(Math.max(margin, left), maxLeft);
|
|
let top = rect.bottom + margin;
|
|
// If not enough space below, place above the trigger
|
|
if (top + ph > window.innerHeight) {
|
|
top = Math.max(margin, rect.top - ph - margin);
|
|
}
|
|
panelStyle.value = { top: `${top}px`, left: `${left}px` };
|
|
};
|
|
|
|
const onWindowChange = () => {
|
|
updatePosition();
|
|
};
|
|
|
|
watch(open, async (val) => {
|
|
if (val) {
|
|
await nextTick();
|
|
updatePosition();
|
|
window.addEventListener('resize', onWindowChange);
|
|
window.addEventListener('scroll', onWindowChange, true);
|
|
} else {
|
|
window.removeEventListener('resize', onWindowChange);
|
|
window.removeEventListener('scroll', onWindowChange, true);
|
|
}
|
|
});
|
|
|
|
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', closeOnEscape);
|
|
window.removeEventListener('resize', onWindowChange);
|
|
window.removeEventListener('scroll', onWindowChange, true);
|
|
});
|
|
|
|
const widthClass = computed(() => {
|
|
const map = {
|
|
'48': 'w-48', // 12rem
|
|
'64': 'w-64', // 16rem
|
|
'72': 'w-72', // 18rem
|
|
'80': 'w-80', // 20rem
|
|
'96': 'w-96', // 24rem
|
|
'wide': 'w-[34rem] max-w-[90vw]',
|
|
'auto': '',
|
|
};
|
|
return map[props.width.toString()] ?? '';
|
|
});
|
|
|
|
const alignmentClasses = computed(() => {
|
|
if (props.align === 'left') {
|
|
return 'ltr:origin-top-left rtl:origin-top-right start-0';
|
|
}
|
|
|
|
if (props.align === 'right') {
|
|
return 'ltr:origin-top-right rtl:origin-top-left end-0';
|
|
}
|
|
|
|
return 'origin-top';
|
|
});
|
|
const onContentClick = () => {
|
|
if (props.closeOnContentClick) {
|
|
open.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="relative" ref="triggerEl">
|
|
<div @click="open = ! open">
|
|
<slot name="trigger" />
|
|
</div>
|
|
|
|
<teleport to="body">
|
|
<!-- Full Screen Dropdown Overlay at body level -->
|
|
<div v-show="open" class="fixed inset-0 z-[2147483646]" @click="open = false" />
|
|
|
|
<transition
|
|
enter-active-class="transition ease-out duration-200"
|
|
enter-from-class="transform opacity-0 scale-95"
|
|
enter-to-class="transform opacity-100 scale-100"
|
|
leave-active-class="transition ease-in duration-75"
|
|
leave-from-class="transform opacity-100 scale-100"
|
|
leave-to-class="transform opacity-0 scale-95"
|
|
>
|
|
<div
|
|
v-show="open"
|
|
ref="panelEl"
|
|
class="fixed z-[2147483647] rounded-md shadow-lg"
|
|
:class="[widthClass]"
|
|
:style="[panelStyle]"
|
|
>
|
|
<div class="rounded-md ring-1 ring-black ring-opacity-5" :class="contentClasses" @click="onContentClick">
|
|
<slot name="content" />
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</teleport>
|
|
</div>
|
|
</template>
|