feat(ui): add Popover component to replace element-plus

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-07-27 18:59:06 -04:00
parent ebf7435221
commit 92552f0d7f
2 changed files with 116 additions and 0 deletions

View file

@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Popover from './Popover.vue';
function make() {
return mount(Popover, {
attachTo: document.body,
slots: {
trigger: '<button class="trg">open</button>',
default: '<div class="content">panel body</div>',
},
});
}
describe('Popover', () => {
it('hides the panel initially', () => {
expect(make().find('[data-testid="panel"]').exists()).toBe(false);
});
it('opens when the trigger is clicked', async () => {
const wrapper = make();
await wrapper.get('.trg').trigger('click');
expect(wrapper.find('[data-testid="panel"]').exists()).toBe(true);
expect(wrapper.emitted('update:visible')?.at(-1)).toEqual([true]);
});
it('closes on Escape', async () => {
const wrapper = make();
await wrapper.get('.trg').trigger('click');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="panel"]').exists()).toBe(false);
});
it('closes on an outside mousedown', async () => {
const wrapper = make();
await wrapper.get('.trg').trigger('click');
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="panel"]').exists()).toBe(false);
});
it('applies a width style to the panel', async () => {
const wrapper = mount(Popover, {
attachTo: document.body,
props: { width: 200 },
slots: { trigger: '<button class="trg">o</button>', default: '<div>b</div>' },
});
await wrapper.get('.trg').trigger('click');
expect(wrapper.get('[data-testid="panel"]').attributes('style')).toContain('width: 200px');
});
});

View file

@ -0,0 +1,64 @@
<template>
<div ref="root" class="relative inline-block">
<div @click="toggle">
<slot name="trigger" :visible="visible"/>
</div>
<div v-if="visible" data-testid="panel"
class="absolute z-50 mt-1 bg-raised border border-edge rounded-lg shadow-lg
p-3 select-none" :class="placementClass" :style="panelStyle">
<slot/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
const props = withDefaults(defineProps<{
width?: number | string;
placement?: 'bottom-start' | 'bottom-end' | 'top-start';
}>(), {
placement: 'bottom-start',
});
const visible = defineModel<boolean>('visible', { default: false });
const root = ref<HTMLElement | null>(null);
function toggle() {
visible.value = !visible.value;
}
function onDocMousedown(e: MouseEvent) {
if (root.value && !root.value.contains(e.target as Node)) visible.value = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') visible.value = false;
}
watch(visible, (open) => {
if (open) {
document.addEventListener('mousedown', onDocMousedown);
document.addEventListener('keydown', onKeydown);
} else {
document.removeEventListener('mousedown', onDocMousedown);
document.removeEventListener('keydown', onKeydown);
}
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDocMousedown);
document.removeEventListener('keydown', onKeydown);
});
const placementClass = computed(() => ({
'bottom-start': 'top-full left-0',
'bottom-end': 'top-full right-0',
'top-start': 'bottom-full left-0 mb-1',
}[props.placement]));
const panelStyle = computed(() =>
props.width === undefined
? undefined
: { width: typeof props.width === 'number' ? `${props.width}px` : props.width });
</script>