Feat: Add popover component with the ability to disable it
This commit is contained in:
parent
a6b68ed3e4
commit
c272fa903d
3 changed files with 380 additions and 0 deletions
350
ui/app/components/Popover.vue
Normal file
350
ui/app/components/Popover.vue
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
<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">
|
||||
<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
|
||||
}
|
||||
|
||||
popover.value.style.minWidth = `${props.minWidth}px`
|
||||
popover.value.style.maxWidth = `${props.maxWidth}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 top = triggerRect.bottom + offset
|
||||
let left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2
|
||||
|
||||
if ('top' == props.placement) {
|
||||
top = triggerRect.top - popoverRect.height - offset
|
||||
} else if ('left' == props.placement) {
|
||||
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2
|
||||
left = triggerRect.left - popoverRect.width - offset
|
||||
} else if ('right' == props.placement) {
|
||||
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 viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
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: `${props.maxWidth}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>
|
||||
|
|
@ -140,6 +140,19 @@
|
|||
Choose the aspect ratio for thumbnail display.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Popover</label>
|
||||
<div class="control">
|
||||
<input id="show_popover" type="checkbox" class="switch is-success" v-model="show_popover">
|
||||
<label for="show_popover" class="is-unselectable">
|
||||
{{ show_popover ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
</div>
|
||||
<p class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
||||
Show additional information over certain elements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
|
|
@ -244,6 +257,7 @@ const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT
|
|||
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true)
|
||||
const toast_target = useStorage<notificationTarget>('toast_target', 'toast')
|
||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
||||
const show_popover = useStorage<boolean>('show_popover', true)
|
||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
||||
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
|
||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
||||
|
|
|
|||
16
ui/app/types/popover.d.ts
vendored
Normal file
16
ui/app/types/popover.d.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right'
|
||||
export type PopoverTrigger = 'hover' | 'click' | 'focus'
|
||||
|
||||
export interface PopoverProps {
|
||||
title?: string
|
||||
description?: string
|
||||
placement?: PopoverPlacement
|
||||
trigger?: PopoverTrigger
|
||||
offset?: number
|
||||
disabled?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
showDelay?: number
|
||||
}
|
||||
Loading…
Reference in a new issue