fix payment qr fallback and admin guidance
This commit is contained in:
@@ -1,23 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, nextTick } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref, useTemplateRef, nextTick } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
content?: string
|
||||
}>()
|
||||
trigger?: 'hover' | 'click'
|
||||
widthClass?: string
|
||||
}>(), {
|
||||
trigger: 'hover',
|
||||
widthClass: 'w-64',
|
||||
})
|
||||
|
||||
const show = ref(false)
|
||||
const triggerRef = useTemplateRef<HTMLElement>('trigger')
|
||||
const tooltipRef = useTemplateRef<HTMLElement>('tooltip')
|
||||
const tooltipStyle = ref({ top: '0px', left: '0px' })
|
||||
|
||||
function onEnter() {
|
||||
function openTooltip() {
|
||||
show.value = true
|
||||
nextTick(updatePosition)
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
function closeTooltip() {
|
||||
show.value = false
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
if (props.trigger !== 'hover') return
|
||||
openTooltip()
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
if (props.trigger !== 'hover') return
|
||||
closeTooltip()
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (props.trigger !== 'click') return
|
||||
event.stopPropagation()
|
||||
if (show.value) {
|
||||
closeTooltip()
|
||||
return
|
||||
}
|
||||
openTooltip()
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (props.trigger !== 'click' || !show.value) return
|
||||
const target = event.target as Node | null
|
||||
if (!target) return
|
||||
if (triggerRef.value?.contains(target) || tooltipRef.value?.contains(target)) return
|
||||
closeTooltip()
|
||||
}
|
||||
|
||||
function onDocumentKeydown(event: KeyboardEvent) {
|
||||
if (props.trigger !== 'click') return
|
||||
if (event.key === 'Escape') {
|
||||
closeTooltip()
|
||||
}
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
if (!show.value) return
|
||||
updatePosition()
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
const el = triggerRef.value
|
||||
if (!el) return
|
||||
@@ -27,6 +73,20 @@ function updatePosition() {
|
||||
left: `${rect.left + rect.width / 2 + window.scrollX}px`,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick, true)
|
||||
document.addEventListener('keydown', onDocumentKeydown)
|
||||
window.addEventListener('resize', onViewportChange)
|
||||
window.addEventListener('scroll', onViewportChange, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
document.removeEventListener('keydown', onDocumentKeydown)
|
||||
window.removeEventListener('resize', onViewportChange)
|
||||
window.removeEventListener('scroll', onViewportChange, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,6 +95,7 @@ function updatePosition() {
|
||||
class="group relative ml-1 inline-flex items-center align-middle"
|
||||
@mouseenter="onEnter"
|
||||
@mouseleave="onLeave"
|
||||
@click="onClick"
|
||||
>
|
||||
<!-- Trigger Icon -->
|
||||
<slot name="trigger">
|
||||
@@ -56,10 +117,26 @@ function updatePosition() {
|
||||
<!-- Teleport to body to escape modal overflow clipping -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="tooltip"
|
||||
v-show="show"
|
||||
class="fixed z-[99999] w-64 -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 dark:bg-gray-800"
|
||||
role="tooltip"
|
||||
:class="[
|
||||
'fixed z-[99999] -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 dark:bg-gray-800',
|
||||
props.widthClass,
|
||||
]"
|
||||
:style="{ top: `calc(${tooltipStyle.top} - 8px)`, left: tooltipStyle.left }"
|
||||
>
|
||||
<button
|
||||
v-if="props.trigger === 'click'"
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1.5 rounded p-1 text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="Close"
|
||||
@click.stop="closeTooltip"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<slot>{{ content }}</slot>
|
||||
<div class="absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
|
||||
80
frontend/src/components/common/__tests__/HelpTooltip.spec.ts
Normal file
80
frontend/src/components/common/__tests__/HelpTooltip.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
|
||||
function getTooltipElement(): HTMLDivElement {
|
||||
const tooltip = document.body.querySelector('[role="tooltip"]')
|
||||
if (!(tooltip instanceof HTMLDivElement)) {
|
||||
throw new Error('tooltip element not found')
|
||||
}
|
||||
return tooltip
|
||||
}
|
||||
|
||||
describe('HelpTooltip', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('keeps the existing hover interaction by default', async () => {
|
||||
const wrapper = mount(HelpTooltip, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
content: 'hover details',
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = wrapper.get('.group')
|
||||
const tooltip = getTooltipElement()
|
||||
|
||||
expect(tooltip.style.display).toBe('none')
|
||||
|
||||
await trigger.trigger('mouseenter')
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).not.toBe('none')
|
||||
|
||||
await trigger.trigger('mouseleave')
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).toBe('none')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('supports click-to-toggle details and closes on outside click', async () => {
|
||||
const wrapper = mount(HelpTooltip, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
content: 'click details',
|
||||
trigger: 'click',
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = wrapper.get('.group')
|
||||
const tooltip = getTooltipElement()
|
||||
|
||||
expect(tooltip.style.display).toBe('none')
|
||||
|
||||
await trigger.trigger('click')
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).not.toBe('none')
|
||||
expect(tooltip.textContent).toContain('click details')
|
||||
|
||||
const closeButton = tooltip.querySelector('button[aria-label="Close"]')
|
||||
if (!(closeButton instanceof HTMLButtonElement)) {
|
||||
throw new Error('close button not found')
|
||||
}
|
||||
closeButton.click()
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).toBe('none')
|
||||
|
||||
await trigger.trigger('click')
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).not.toBe('none')
|
||||
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).toBe('none')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user