From 81ef0cc43f0649e7035b9964040716d5dc0f10e9 Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Sat, 24 Oct 2026 16:44:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/BoardToolbar.spec.ts | 22 +++ src/components/BoardToolbar.vue | 268 ++++++++++++++++++---------- src/components/TagFilter.spec.ts | 33 ++++ src/components/TagFilter.vue | 41 ++++- 4 files changed, 268 insertions(+), 96 deletions(-) diff --git a/src/components/BoardToolbar.spec.ts b/src/components/BoardToolbar.spec.ts index f323aa5..44403ad 100644 --- a/src/components/BoardToolbar.spec.ts +++ b/src/components/BoardToolbar.spec.ts @@ -231,3 +231,25 @@ describe('BoardToolbar 多选', () => { expect(exactButton(wrapper, '多选')).toBeUndefined() }) }) + +describe('BoardToolbar 溢出容器', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + /** + * 回归守卫:筛选组一旦变成滚动容器(overflow-x-auto 会让 overflow-y 也算 auto), + * 「标签」下拉面板就会被裁在 36px 高的行里 —— 点开等于没反应,右侧还多一条滚动条。 + * 空间不够时应当换行(flex-wrap),而不是把内容推到滚动区外。 + */ + it('筛选组不是滚动容器,标签面板不会被裁', () => { + const { wrapper } = setup() + const chip = buttonByText(wrapper, '今日到期') + const group = chip?.element.parentElement + + expect(group).toBeTruthy() + const classes = group?.className ?? '' + expect(classes).toContain('flex-wrap') + expect(classes).not.toMatch(/overflow-(x-|y-)?(auto|scroll|hidden)/) + }) +}) diff --git a/src/components/BoardToolbar.vue b/src/components/BoardToolbar.vue index 29725d7..e2e7ec5 100644 --- a/src/components/BoardToolbar.vue +++ b/src/components/BoardToolbar.vue @@ -47,12 +47,17 @@ defineExpose({ focusSearch }) diff --git a/src/components/TagFilter.spec.ts b/src/components/TagFilter.spec.ts index a19f2b9..b9fe889 100644 --- a/src/components/TagFilter.spec.ts +++ b/src/components/TagFilter.spec.ts @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' import { afterEach, describe, expect, it } from 'vitest' import TagFilter from './TagFilter.vue' @@ -137,3 +138,35 @@ describe('TagFilter 空状态', () => { expect(wrapper.text()).toContain('还没有任何标签') }) }) + +describe('TagFilter 面板定位', () => { + it('靠近视口右缘时把面板往左推回视口内', async () => { + const wrapper = mountFilter() + const root = wrapper.element as HTMLElement + // 模拟「标签」被挤到窗口右侧 60px 处:224px 的面板必然伸出窗口 + root.getBoundingClientRect = () => + ({ left: window.innerWidth - 60, top: 0, width: 60, height: 36 }) as DOMRect + + await triggerButton(wrapper).trigger('click') + await nextTick() + + const panel = wrapper.find('div.absolute').element as HTMLElement + const shift = Number.parseFloat(panel.style.marginLeft || '0') + expect(shift).toBeLessThan(0) + // 右缘不能越过视口 + expect(window.innerWidth - 60 + shift + 224).toBeLessThanOrEqual(window.innerWidth) + }) + + it('左侧空间充足时不偏移', async () => { + const wrapper = mountFilter() + const root = wrapper.element as HTMLElement + root.getBoundingClientRect = () => + ({ left: 100, top: 0, width: 60, height: 36 }) as DOMRect + + await triggerButton(wrapper).trigger('click') + await nextTick() + + const panel = wrapper.find('div.absolute').element as HTMLElement + expect(Number.parseFloat(panel.style.marginLeft || '0')).toBe(0) + }) +}) diff --git a/src/components/TagFilter.vue b/src/components/TagFilter.vue index b5f0ff8..5e53901 100644 --- a/src/components/TagFilter.vue +++ b/src/components/TagFilter.vue @@ -6,7 +6,7 @@ * 改成下拉面板:平时只占一个按钮,选中的数量用角标提示。 */ -import { onBeforeUnmount, onMounted, ref } from 'vue' +import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' defineProps<{ tags: readonly string[]; selected: readonly string[] }>() @@ -15,6 +15,34 @@ const emit = defineEmits<{ toggle: [tag: string]; clear: []; manage: [] }>() const open = ref(false) const rootRef = ref(null) +/** + * 面板固定 224px(w-56)且默认左对齐触发按钮。 + * 当「标签」被挤到靠近视口右缘时(窄屏工具栏、筛选换行的情况), + * 面板会伸出窗口被裁掉 —— 这里把面板往左推回视口内。 + * 用 margin-left 而不是 transform:面板自身的 pop-in 动画也在动 transform,两者会打架。 + */ +const PANEL_WIDTH = 224 +const EDGE = 8 +const shiftX = ref(0) + +function reposition(): void { + const root = rootRef.value + if (!root) return + const rect = root.getBoundingClientRect() + const overflowRight = rect.left + PANEL_WIDTH - (window.innerWidth - EDGE) + shiftX.value = overflowRight > 0 ? -Math.min(overflowRight, Math.max(rect.left - EDGE, 0)) : 0 +} + +function onResize(): void { + if (open.value) reposition() +} + +watch(open, async (value) => { + if (!value) return + await nextTick() + reposition() +}) + function onDocumentClick(event: MouseEvent): void { if (!open.value) return const root = rootRef.value @@ -38,11 +66,13 @@ function openManager(): void { onMounted(() => { document.addEventListener('click', onDocumentClick) document.addEventListener('keydown', onDocumentKeydown, true) + window.addEventListener('resize', onResize) }) onBeforeUnmount(() => { document.removeEventListener('click', onDocumentClick) document.removeEventListener('keydown', onDocumentKeydown, true) + window.removeEventListener('resize', onResize) }) @@ -50,10 +80,10 @@ onBeforeUnmount(() => {