From 005a5d2a372deff5fc5f0d778f5c078978dd992e Mon Sep 17 00:00:00 2001 From: Cynicismcart <62606741+Cynicismcart@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:35:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=E5=88=86?= =?UTF-8?q?=E7=BB=84=E6=8F=8F=E8=BF=B0=E6=8D=A2=E8=A1=8C=E5=92=8C=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E6=A1=86=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/common/GroupOptionItem.vue | 2 +- frontend/src/components/common/Select.vue | 9 ++- .../common/__tests__/GroupOptionItem.spec.ts | 44 +++++++++++++ .../common/__tests__/Select.spec.ts | 63 +++++++++++++++++++ 4 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/common/__tests__/GroupOptionItem.spec.ts create mode 100644 frontend/src/components/common/__tests__/Select.spec.ts diff --git a/frontend/src/components/common/GroupOptionItem.vue b/frontend/src/components/common/GroupOptionItem.vue index 83acde538..1bf44471f 100644 --- a/frontend/src/components/common/GroupOptionItem.vue +++ b/frontend/src/components/common/GroupOptionItem.vue @@ -16,7 +16,7 @@ {{ description }} diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index 13548a13a..04eae521b 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -184,6 +184,7 @@ const dropdownRef = ref(null) const optionsListRef = ref(null) const dropdownPosition = ref<'bottom' | 'top'>('bottom') const triggerRect = ref(null) +const dropdownViewportPadding = 8 // i18n placeholders const placeholderText = computed(() => props.placeholder ?? t('common.selectOption')) @@ -200,10 +201,14 @@ const dropdownStyle = computed(() => { if (!triggerRect.value) return {} const rect = triggerRect.value + const left = Math.max(dropdownViewportPadding, rect.left) + const availableWidth = Math.max(0, window.innerWidth - left - dropdownViewportPadding) + const minWidth = Math.min(rect.width, availableWidth) const style: Record = { position: 'fixed', - left: `${rect.left}px`, - minWidth: `${rect.width}px`, + left: `${left}px`, + minWidth: `${minWidth}px`, + maxWidth: `${availableWidth}px`, zIndex: '100000020' } diff --git a/frontend/src/components/common/__tests__/GroupOptionItem.spec.ts b/frontend/src/components/common/__tests__/GroupOptionItem.spec.ts new file mode 100644 index 000000000..e0f2590b6 --- /dev/null +++ b/frontend/src/components/common/__tests__/GroupOptionItem.spec.ts @@ -0,0 +1,44 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import GroupOptionItem from '../GroupOptionItem.vue' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }), + } +}) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ cachedPublicSettings: null }), +})) + +describe('GroupOptionItem description layout', () => { + it('applies multiline and overflow-safe text styles', () => { + const description = 'First section\nvery-long-unbroken-description-value-that-must-not-overflow' + const wrapper = mount(GroupOptionItem, { + props: { + name: 'Example group', + platform: 'openai', + description, + }, + global: { + stubs: { + GroupBadge: true, + }, + }, + }) + + const descriptionElement = wrapper + .findAll('span') + .find((element) => element.text() === description) + + expect(descriptionElement).toBeDefined() + expect(descriptionElement?.classes()).toContain('whitespace-pre-line') + expect(descriptionElement?.classes()).toContain('[overflow-wrap:anywhere]') + expect(descriptionElement?.classes()).toContain('line-clamp-3') + expect(wrapper.find('[title]').attributes('title')).toBe(description) + }) +}) diff --git a/frontend/src/components/common/__tests__/Select.spec.ts b/frontend/src/components/common/__tests__/Select.spec.ts new file mode 100644 index 000000000..d53be04f1 --- /dev/null +++ b/frontend/src/components/common/__tests__/Select.spec.ts @@ -0,0 +1,63 @@ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import Select from '../Select.vue' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }), + } +}) + +afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +describe('Select dropdown viewport constraints', () => { + it('limits the teleported dropdown to the available viewport width', async () => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 320, + }) + + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 220, + y: 20, + top: 20, + right: 300, + bottom: 60, + left: 220, + width: 80, + height: 40, + toJSON: () => ({}), + }) + + const wrapper = mount(Select, { + props: { + modelValue: null, + options: [ + { + value: 'example', + label: 'very-long-unbroken-option-value-that-must-not-overflow', + }, + ], + }, + }) + + await wrapper.get('button').trigger('click') + await nextTick() + + const dropdown = document.body.querySelector('.select-dropdown-portal') + + expect(dropdown).not.toBeNull() + expect(dropdown?.style.left).toBe('220px') + expect(dropdown?.style.minWidth).toBe('80px') + expect(dropdown?.style.maxWidth).toBe('92px') + + wrapper.unmount() + }) +})