mirror of
https://github.com/lights0123/n-link.git
synced 2026-08-07 18:23:28 +00:00
feat(ui): add Popover component to replace element-plus
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ebf7435221
commit
92552f0d7f
52
n-link-core/components/Popover.spec.ts
Normal file
52
n-link-core/components/Popover.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
64
n-link-core/components/Popover.vue
Normal file
64
n-link-core/components/Popover.vue
Normal 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>
|
||||
Loading…
Reference in a new issue