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