413 lines
9.9 KiB
Vue
413 lines
9.9 KiB
Vue
<template>
|
|
<div
|
|
class="popover-wrapper"
|
|
ref="triggerElm"
|
|
:tabindex="triggerTabIndex"
|
|
@mouseenter="handleHoverEnter"
|
|
@mouseleave="handleHoverLeave"
|
|
@focusin="handleFocusIn"
|
|
@focusout="handleFocusOut"
|
|
@click="handleClick"
|
|
>
|
|
<div class="popover-trigger">
|
|
<slot name="trigger">
|
|
<button type="button" class="button is-small is-rounded" aria-label="More information">
|
|
<span class="icon is-small">
|
|
<i class="fas fa-info-circle" aria-hidden="true" />
|
|
</span>
|
|
</button>
|
|
</slot>
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<div v-if="isOpen" class="popover-portal" ref="portal">
|
|
<div
|
|
ref="popover"
|
|
class="popover-card box"
|
|
:class="placementClass"
|
|
role="tooltip"
|
|
:style="portalStyle"
|
|
@mouseenter="handleHoverEnter"
|
|
@mouseleave="handleHoverLeave"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="button is-bold is-fullwidth is-hidden-tablet"
|
|
@click="closePopover"
|
|
>
|
|
<span class="icon"><i class="fas fa-times" /></span>
|
|
<span>Close</span>
|
|
</button>
|
|
|
|
<header v-if="hasTitleContent" class="popover-title mb-2">
|
|
<slot name="title">
|
|
<p class="title is-6 mb-1">{{ title }}</p>
|
|
</slot>
|
|
</header>
|
|
|
|
<section v-if="hasBodyContent" class="popover-body">
|
|
<slot>
|
|
<p class="is-size-7 mb-0">{{ description }}</p>
|
|
</slot>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { PopoverProps } from '~/types/popover';
|
|
import {
|
|
computed,
|
|
nextTick,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
ref,
|
|
useSlots,
|
|
watch,
|
|
useTemplateRef,
|
|
} from 'vue';
|
|
|
|
const emit = defineEmits<{ (event: 'shown' | 'hidden'): void }>();
|
|
|
|
const props = withDefaults(defineProps<PopoverProps>(), {
|
|
placement: 'top',
|
|
trigger: 'hover',
|
|
offset: 10,
|
|
disabled: false,
|
|
closeOnClickOutside: true,
|
|
title: '',
|
|
description: '',
|
|
minWidth: 220,
|
|
maxWidth: 340,
|
|
maxHeight: 360,
|
|
showDelay: 0,
|
|
});
|
|
|
|
const slots = useSlots();
|
|
const isOpen = ref(false);
|
|
const triggerRef = useTemplateRef<HTMLElement>('triggerElm');
|
|
const popover = useTemplateRef<HTMLDivElement>('popover');
|
|
const portalStyle = ref<Record<string, string>>({});
|
|
const hoverTimeout = ref<number | null>(null);
|
|
const openTimeout = ref<number | null>(null);
|
|
|
|
const placementClass = computed(() => `is-${props.placement}`);
|
|
const triggerTabIndex = computed(() => (props.trigger === 'hover' ? -1 : 0));
|
|
|
|
const hasTitleContent = computed(() => Boolean(props.title) || Boolean(slots.title));
|
|
const hasBodyContent = computed(() => Boolean(props.description) || Boolean(slots.default));
|
|
|
|
const clearHoverTimeout = () => {
|
|
if (null !== hoverTimeout.value) {
|
|
window.clearTimeout(hoverTimeout.value);
|
|
hoverTimeout.value = null;
|
|
}
|
|
};
|
|
|
|
const clearOpenTimeout = () => {
|
|
if (null !== openTimeout.value) {
|
|
window.clearTimeout(openTimeout.value);
|
|
openTimeout.value = null;
|
|
}
|
|
};
|
|
|
|
const scheduleClose = () => {
|
|
clearHoverTimeout();
|
|
clearOpenTimeout();
|
|
hoverTimeout.value = window.setTimeout(() => {
|
|
isOpen.value = false;
|
|
}, 120);
|
|
};
|
|
|
|
const openPopoverImmediate = async () => {
|
|
if (props.disabled || isOpen.value) {
|
|
return;
|
|
}
|
|
|
|
isOpen.value = true;
|
|
await nextTick();
|
|
updatePosition();
|
|
};
|
|
|
|
const openPopover = () => {
|
|
clearOpenTimeout();
|
|
if (props.showDelay && 0 < props.showDelay) {
|
|
openTimeout.value = window.setTimeout(() => {
|
|
openTimeout.value = null;
|
|
void openPopoverImmediate();
|
|
}, props.showDelay);
|
|
return;
|
|
}
|
|
|
|
void openPopoverImmediate();
|
|
};
|
|
|
|
const closePopover = () => {
|
|
if (!isOpen.value) {
|
|
return;
|
|
}
|
|
|
|
isOpen.value = false;
|
|
};
|
|
|
|
const togglePopover = async () => {
|
|
if (isOpen.value) {
|
|
closePopover();
|
|
return;
|
|
}
|
|
|
|
openPopover();
|
|
};
|
|
|
|
const handleHoverEnter = () => {
|
|
if ('hover' !== props.trigger || props.disabled) {
|
|
return;
|
|
}
|
|
|
|
clearHoverTimeout();
|
|
openPopover();
|
|
};
|
|
|
|
const handleHoverLeave = () => {
|
|
if ('hover' !== props.trigger) {
|
|
return;
|
|
}
|
|
|
|
clearOpenTimeout();
|
|
scheduleClose();
|
|
};
|
|
|
|
const handleFocusIn = () => {
|
|
if ('focus' !== props.trigger || props.disabled) {
|
|
return;
|
|
}
|
|
|
|
openPopover();
|
|
};
|
|
|
|
const handleFocusOut = () => {
|
|
if ('focus' !== props.trigger) {
|
|
return;
|
|
}
|
|
|
|
clearOpenTimeout();
|
|
scheduleClose();
|
|
};
|
|
|
|
const handleClick = (event: MouseEvent) => {
|
|
if ('click' !== props.trigger || props.disabled) {
|
|
return;
|
|
}
|
|
|
|
event.stopPropagation();
|
|
void togglePopover();
|
|
};
|
|
|
|
const handleDocumentClick = (event: MouseEvent) => {
|
|
if (!props.closeOnClickOutside || 'click' !== props.trigger) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target as Node | null;
|
|
const isInsideTrigger = Boolean(triggerRef.value && triggerRef.value.contains(target));
|
|
const isInsidePopover = Boolean(popover.value && popover.value.contains(target));
|
|
|
|
if (!isInsideTrigger && !isInsidePopover) {
|
|
clearOpenTimeout();
|
|
closePopover();
|
|
}
|
|
};
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if ('Escape' === event.key && isOpen.value) {
|
|
closePopover();
|
|
}
|
|
};
|
|
|
|
const updatePosition = () => {
|
|
if (!triggerRef.value || !popover.value) {
|
|
return;
|
|
}
|
|
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
const constrainedMaxWidth = Math.min(props.maxWidth, viewportWidth * 0.96);
|
|
|
|
popover.value.style.minWidth = `${props.minWidth}px`;
|
|
popover.value.style.maxWidth = `${constrainedMaxWidth}px`;
|
|
popover.value.style.maxHeight = `${props.maxHeight}px`;
|
|
popover.value.style.overflowY = 'auto';
|
|
|
|
const triggerRect = triggerRef.value.getBoundingClientRect();
|
|
const popoverRect = popover.value.getBoundingClientRect();
|
|
const offset = props.offset;
|
|
|
|
let effectivePlacement = props.placement;
|
|
|
|
if ('top' === props.placement) {
|
|
const spaceAbove = triggerRect.top - offset;
|
|
if (spaceAbove < popoverRect.height) {
|
|
effectivePlacement = 'bottom';
|
|
}
|
|
} else if ('bottom' === props.placement) {
|
|
const spaceBelow = viewportHeight - triggerRect.bottom - offset;
|
|
if (spaceBelow < popoverRect.height) {
|
|
effectivePlacement = 'top';
|
|
}
|
|
} else if ('left' === props.placement) {
|
|
const spaceLeft = triggerRect.left - offset;
|
|
if (spaceLeft < popoverRect.width) {
|
|
effectivePlacement = 'right';
|
|
}
|
|
} else if ('right' === props.placement) {
|
|
const spaceRight = viewportWidth - triggerRect.right - offset;
|
|
if (spaceRight < popoverRect.width) {
|
|
effectivePlacement = 'left';
|
|
}
|
|
}
|
|
|
|
let top = triggerRect.bottom + offset;
|
|
let left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
|
|
|
|
if ('top' === effectivePlacement) {
|
|
top = triggerRect.top - popoverRect.height - offset;
|
|
} else if ('left' === effectivePlacement) {
|
|
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
|
|
left = triggerRect.left - popoverRect.width - offset;
|
|
} else if ('right' === effectivePlacement) {
|
|
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
|
|
left = triggerRect.right + offset;
|
|
}
|
|
|
|
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
|
const maxLeft = viewportWidth - popoverRect.width - 8;
|
|
const maxTop = viewportHeight - popoverRect.height - 8;
|
|
|
|
const clampedTop = clamp(Math.round(top), 8, maxTop);
|
|
const clampedLeft = clamp(Math.round(left), 8, maxLeft);
|
|
|
|
portalStyle.value = {
|
|
position: 'fixed',
|
|
top: `${clampedTop}px`,
|
|
left: `${clampedLeft}px`,
|
|
minWidth: `${props.minWidth}px`,
|
|
maxWidth: `${constrainedMaxWidth}px`,
|
|
maxHeight: `${props.maxHeight}px`,
|
|
overflowY: 'auto',
|
|
};
|
|
};
|
|
|
|
watch(isOpen, async (value) => {
|
|
if (value) {
|
|
await nextTick();
|
|
updatePosition();
|
|
await nextTick();
|
|
emit('shown');
|
|
return;
|
|
}
|
|
|
|
emit('hidden');
|
|
});
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', handleDocumentClick, true);
|
|
document.addEventListener('keydown', handleEscape);
|
|
window.addEventListener('resize', updatePosition);
|
|
window.addEventListener('scroll', updatePosition, true);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('click', handleDocumentClick, true);
|
|
document.removeEventListener('keydown', handleEscape);
|
|
window.removeEventListener('resize', updatePosition);
|
|
window.removeEventListener('scroll', updatePosition, true);
|
|
clearHoverTimeout();
|
|
clearOpenTimeout();
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.popover-wrapper {
|
|
position: relative;
|
|
display: inline-flex;
|
|
max-width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.popover-trigger {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
max-width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.popover-trigger > * {
|
|
max-width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.popover-portal {
|
|
position: fixed;
|
|
z-index: 1050;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.popover-card {
|
|
position: fixed;
|
|
background-color: var(--bulma-scheme-main, #fff);
|
|
color: var(--bulma-text, #363636);
|
|
border: 1px solid var(--bulma-border, rgba(0, 0, 0, 0.08));
|
|
box-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)
|
|
);
|
|
border-radius: var(--bulma-radius-large, 0.5rem);
|
|
pointer-events: auto;
|
|
padding: 0.9rem 1rem;
|
|
overflow-wrap: anywhere;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.popover-title {
|
|
color: var(--bulma-text-strong, #1f1f1f);
|
|
}
|
|
|
|
.popover-card::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 12px;
|
|
height: 12px;
|
|
background: inherit;
|
|
border-left: inherit;
|
|
border-top: inherit;
|
|
transform: rotate(45deg);
|
|
}
|
|
|
|
.popover-card.is-top::after {
|
|
bottom: -6px;
|
|
left: 50%;
|
|
transform: translateX(-50%) rotate(45deg);
|
|
}
|
|
|
|
.popover-card.is-bottom::after {
|
|
top: -6px;
|
|
left: 50%;
|
|
transform: translateX(-50%) rotate(45deg);
|
|
}
|
|
|
|
.popover-card.is-left::after {
|
|
right: -6px;
|
|
top: 50%;
|
|
transform: translateY(-50%) rotate(45deg);
|
|
}
|
|
|
|
.popover-card.is-right::after {
|
|
left: -6px;
|
|
top: 50%;
|
|
transform: translateY(-50%) rotate(45deg);
|
|
}
|
|
</style>
|