Skip to content

Commit 91d3b99

Browse files
committed
feat: add AppPopover component
1 parent ed73a8b commit 91d3b99

1 file changed

Lines changed: 79 additions & 0 deletions

File tree

app/components/AppPopover.vue

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
/** Position of the popover panel: 'top' | 'bottom' | 'left' | 'right' */
4+
position?: 'top' | 'bottom' | 'left' | 'right'
5+
}>()
6+
7+
const isOpen = shallowRef(false)
8+
const popoverId = useId()
9+
const closeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
10+
11+
const closeDelayMs = 500
12+
13+
// Panel placement relative to trigger
14+
const panelPositionClasses: Record<string, string> = {
15+
top: 'bottom-full left-1/2 -translate-x-1/2',
16+
bottom: 'top-full left-1/2 -translate-x-1/2',
17+
left: 'right-full top-1/2 -translate-y-1/2',
18+
right: 'left-full top-1/2 -translate-y-1/2',
19+
}
20+
21+
const panelPosition = computed(() => panelPositionClasses[props.position ?? 'bottom'])
22+
23+
function clearCloseTimeout() {
24+
if (closeTimeout.value !== null) {
25+
clearTimeout(closeTimeout.value)
26+
closeTimeout.value = null
27+
}
28+
}
29+
30+
function open() {
31+
clearCloseTimeout()
32+
isOpen.value = true
33+
}
34+
35+
function close() {
36+
clearCloseTimeout()
37+
closeTimeout.value = setTimeout(() => {
38+
closeTimeout.value = null
39+
isOpen.value = false
40+
}, closeDelayMs)
41+
}
42+
43+
onBeforeUnmount(clearCloseTimeout)
44+
</script>
45+
46+
<template>
47+
<div
48+
class="relative inline-flex"
49+
:aria-expanded="isOpen"
50+
:aria-haspopup="true"
51+
:aria-controls="isOpen ? popoverId : undefined"
52+
@mouseenter="open"
53+
@mouseleave="close"
54+
@focusin="open"
55+
@focusout="close"
56+
>
57+
<slot :popover-visible="isOpen" />
58+
59+
<Transition
60+
enter-active-class="transition-opacity duration-150 motion-reduce:transition-none"
61+
leave-active-class="transition-opacity duration-100 motion-reduce:transition-none"
62+
enter-from-class="opacity-0"
63+
leave-to-class="opacity-0"
64+
>
65+
<div
66+
v-if="isOpen"
67+
:id="popoverId"
68+
role="dialog"
69+
aria-modal="false"
70+
class="absolute font-mono text-xs text-fg bg-bg-elevated border border-border rounded-lg shadow-lg z-[100] pointer-events-auto px-4 py-3 min-w-[14rem] max-w-[22rem] whitespace-normal"
71+
:class="panelPosition"
72+
@mouseenter="open"
73+
@mouseleave="close"
74+
>
75+
<slot name="content" />
76+
</div>
77+
</Transition>
78+
</div>
79+
</template>

0 commit comments

Comments
 (0)