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()
+ })
+})