218 lines
5.6 KiB
Vue
218 lines
5.6 KiB
Vue
<template>
|
|
<div class="dropdown" :class="{ 'is-active': isOpen, 'drop-up': dropUp }" ref="dropdown">
|
|
<div class="dropdown-trigger">
|
|
<button
|
|
class="button is-fullwidth is-justify-content-space-between"
|
|
aria-haspopup="true"
|
|
type="button"
|
|
aria-controls="dropdown-menu"
|
|
@click="toggle"
|
|
:class="button_classes"
|
|
>
|
|
<span class="icon" v-if="icons"><i :class="icons" /></span>
|
|
<span :class="{ 'is-sr-only': hideLabel }">{{ label }}</span>
|
|
<div class="is-pulled-right">
|
|
<span class="icon"><i class="fas fa-angle-down" aria-hidden="true" /></span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<div v-if="isOpen" class="dropdown is-active dropdown-portal" ref="menu" :style="menuStyle">
|
|
<div class="dropdown-menu" role="menu">
|
|
<div class="dropdown-content is-unselectable" @click="handle_slot_click">
|
|
<template v-if="hideLabel">
|
|
<div class="dropdown-label">
|
|
<span class="icon-text">
|
|
<span class="icon" v-if="icons"><i :class="icons" /></span>
|
|
<span>{{ label }}</span>
|
|
</span>
|
|
</div>
|
|
<div class="dropdown-divider"></div>
|
|
</template>
|
|
<slot />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onBeforeUnmount, nextTick, watchEffect, useTemplateRef } from 'vue';
|
|
|
|
const emitter = defineEmits(['open_state']);
|
|
const props = defineProps({
|
|
label: {
|
|
type: String,
|
|
default: 'Select',
|
|
},
|
|
icons: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
button_classes: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
hide_label_on_mobile: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
});
|
|
|
|
const isOpen = ref(false);
|
|
const dropUp = ref(false);
|
|
const dropdown = useTemplateRef<HTMLDivElement>('dropdown');
|
|
const menu = useTemplateRef<HTMLDivElement>('menu');
|
|
const menuStyle = ref<Record<string, string>>({});
|
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
|
|
|
const hideLabel = computed(() => isMobile.value && props.hide_label_on_mobile);
|
|
|
|
const updatePosition = () => {
|
|
if (!dropdown.value || !isOpen.value) {
|
|
return;
|
|
}
|
|
|
|
const triggerRect = dropdown.value.getBoundingClientRect();
|
|
const menuHeight = menu.value?.offsetHeight || 300;
|
|
const spaceBelow = window.innerHeight - triggerRect.bottom;
|
|
const spaceAbove = triggerRect.top;
|
|
|
|
// Determine if dropdown should appear above or below
|
|
const shouldDropUp = spaceBelow < menuHeight + 24 && spaceAbove > spaceBelow;
|
|
dropUp.value = shouldDropUp;
|
|
|
|
// Calculate position
|
|
const left = triggerRect.left;
|
|
const width = triggerRect.width;
|
|
|
|
if (shouldDropUp) {
|
|
// Position above the trigger
|
|
const bottom = window.innerHeight - triggerRect.top;
|
|
menuStyle.value = {
|
|
position: 'fixed',
|
|
left: `${left}px`,
|
|
bottom: `${bottom}px`,
|
|
width: `${width}px`,
|
|
top: 'auto',
|
|
};
|
|
} else {
|
|
// Position below the trigger
|
|
const top = triggerRect.bottom;
|
|
menuStyle.value = {
|
|
position: 'fixed',
|
|
left: `${left}px`,
|
|
top: `${top}px`,
|
|
width: `${width}px`,
|
|
bottom: 'auto',
|
|
};
|
|
}
|
|
};
|
|
|
|
const toggle = async () => {
|
|
isOpen.value = !isOpen.value;
|
|
|
|
if (isOpen.value) {
|
|
await nextTick();
|
|
updatePosition();
|
|
}
|
|
};
|
|
|
|
const handle_slot_click = (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest('.dropdown-item')) {
|
|
isOpen.value = false;
|
|
}
|
|
};
|
|
|
|
const handle_event = (event: MouseEvent) => {
|
|
if (!dropdown.value) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target as HTMLElement;
|
|
|
|
if (!dropdown.value.contains(target) && !menu.value?.contains(target)) {
|
|
isOpen.value = false;
|
|
}
|
|
};
|
|
|
|
const handleScroll = () => {
|
|
if (isOpen.value) {
|
|
updatePosition();
|
|
}
|
|
};
|
|
|
|
const handleResize = () => {
|
|
if (isOpen.value) {
|
|
updatePosition();
|
|
}
|
|
};
|
|
|
|
watchEffect(() => emitter('open_state', isOpen.value));
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', handle_event);
|
|
window.addEventListener('scroll', handleScroll, true); // Use capture to catch all scroll events
|
|
window.addEventListener('resize', handleResize);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('click', handle_event);
|
|
window.removeEventListener('scroll', handleScroll, true);
|
|
window.removeEventListener('resize', handleResize);
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dropdown {
|
|
width: 100%;
|
|
position: relative;
|
|
}
|
|
|
|
.dropdown-trigger {
|
|
width: 100%;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
.dropdown.dropdown-portal {
|
|
position: fixed;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.dropdown.dropdown-portal .dropdown-menu {
|
|
display: block !important;
|
|
/* Override Bulma's display: none */
|
|
position: static;
|
|
/* Don't use absolute positioning inside fixed container */
|
|
max-height: 350px;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
min-width: max-content;
|
|
padding-top: 4px;
|
|
}
|
|
|
|
.dropdown.dropdown-portal .dropdown-content {
|
|
background-color: var(--bulma-dropdown-content-background-color, var(--bulma-scheme-main, #fff));
|
|
border-radius: var(--bulma-dropdown-content-radius, var(--bulma-radius, 4px));
|
|
box-shadow: var(
|
|
--bulma-dropdown-content-shadow,
|
|
var(
|
|
--bulma-shadow,
|
|
0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1),
|
|
0 0px 0 1px rgba(10, 10, 10, 0.02)
|
|
)
|
|
);
|
|
padding-top: var(--bulma-dropdown-content-padding-top, 0.5rem);
|
|
padding-bottom: var(--bulma-dropdown-content-padding-bottom, 0.5rem);
|
|
}
|
|
|
|
.dropdown-label {
|
|
font-weight: 600;
|
|
padding: 0.5rem 0.75rem;
|
|
color: var(--bulma-dropdown-label-color, var(--bulma-text-color, #4a4a4a));
|
|
}
|
|
</style>
|