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 1/2] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E5=88=86=E7=BB=84=E6=8F=8F=E8=BF=B0=E6=8D=A2=E8=A1=8C=E5=92=8C?=
=?UTF-8?q?=E4=B8=8B=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 83acde5386..1bf44471f4 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 13548a13a0..04eae521b1 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 0000000000..e0f2590b6e
--- /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 0000000000..d53be04f1f
--- /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()
+ })
+})
From 78f78947f1c4d381d766cd9f8558407ab9d93a0a Mon Sep 17 00:00:00 2001
From: Cynicismcart <62606741+Cynicismcart@users.noreply.github.com>
Date: Mon, 27 Jul 2026 00:41:40 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix(frontend):=20=E5=AE=8C=E5=96=84?=
=?UTF-8?q?=E4=B8=8B=E6=8B=89=E6=A1=86=E8=A7=86=E5=8F=A3=E8=BE=B9=E7=95=8C?=
=?UTF-8?q?=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/components/common/Select.vue | 12 +-
.../common/__tests__/Select.spec.ts | 116 +++++++++++++-----
2 files changed, 93 insertions(+), 35 deletions(-)
diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue
index 04eae521b1..606e6300bf 100644
--- a/frontend/src/components/common/Select.vue
+++ b/frontend/src/components/common/Select.vue
@@ -185,6 +185,7 @@ const optionsListRef = ref(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
const triggerRect = ref(null)
const dropdownViewportPadding = 8
+const dropdownMinimumWidth = 200
// i18n placeholders
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
@@ -201,9 +202,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 viewportRight = Math.max(dropdownViewportPadding, window.innerWidth - dropdownViewportPadding)
+ const left = Math.min(
+ Math.max(dropdownViewportPadding, rect.left),
+ viewportRight
+ )
+ const availableWidth = Math.max(0, viewportRight - left)
+ const preferredMinWidth = Math.max(dropdownMinimumWidth, rect.width)
+ const minWidth = Math.min(preferredMinWidth, availableWidth)
const style: Record = {
position: 'fixed',
left: `${left}px`,
diff --git a/frontend/src/components/common/__tests__/Select.spec.ts b/frontend/src/components/common/__tests__/Select.spec.ts
index d53be04f1f..0817cad1a9 100644
--- a/frontend/src/components/common/__tests__/Select.spec.ts
+++ b/frontend/src/components/common/__tests__/Select.spec.ts
@@ -12,52 +12,104 @@ vi.mock('vue-i18n', async () => {
}
})
+const originalInnerWidth = window.innerWidth
+let unmountWrapper: (() => void) | undefined
+
+const setViewportWidth = (width: number) => {
+ Object.defineProperty(window, 'innerWidth', {
+ configurable: true,
+ value: width,
+ })
+}
+
+const mockTriggerRect = (left: number, width: number) => {
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
+ x: left,
+ y: 20,
+ top: 20,
+ right: left + width,
+ bottom: 60,
+ left,
+ width,
+ height: 40,
+ toJSON: () => ({}),
+ })
+}
+
+const openSelect = async () => {
+ const wrapper = mount(Select, {
+ props: {
+ modelValue: null,
+ options: [
+ {
+ value: 'example',
+ label: 'very-long-unbroken-option-value-that-must-not-overflow',
+ },
+ ],
+ },
+ })
+ unmountWrapper = () => wrapper.unmount()
+
+ await wrapper.get('button').trigger('click')
+ await nextTick()
+
+ return document.body.querySelector('.select-dropdown-portal')
+}
+
afterEach(() => {
+ unmountWrapper?.()
+ unmountWrapper = undefined
document.body.innerHTML = ''
+ setViewportWidth(originalInnerWidth)
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,
- })
+ it('preserves the existing 200px minimum width when space is available', async () => {
+ setViewportWidth(1024)
+ mockTriggerRect(20, 80)
- vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
- x: 220,
- y: 20,
- top: 20,
- right: 300,
- bottom: 60,
- left: 220,
- width: 80,
- height: 40,
- toJSON: () => ({}),
- })
+ const dropdown = await openSelect()
- const wrapper = mount(Select, {
- props: {
- modelValue: null,
- options: [
- {
- value: 'example',
- label: 'very-long-unbroken-option-value-that-must-not-overflow',
- },
- ],
- },
- })
+ expect(dropdown).not.toBeNull()
+ expect(dropdown?.style.left).toBe('20px')
+ expect(dropdown?.style.minWidth).toBe('200px')
+ expect(dropdown?.style.maxWidth).toBe('996px')
+ })
- await wrapper.get('button').trigger('click')
- await nextTick()
+ it('shrinks the minimum width to fit near the right viewport edge', async () => {
+ setViewportWidth(320)
+ mockTriggerRect(220, 80)
- const dropdown = document.body.querySelector('.select-dropdown-portal')
+ const dropdown = await openSelect()
expect(dropdown).not.toBeNull()
expect(dropdown?.style.left).toBe('220px')
- expect(dropdown?.style.minWidth).toBe('80px')
+ expect(dropdown?.style.minWidth).toBe('92px')
expect(dropdown?.style.maxWidth).toBe('92px')
+ })
- wrapper.unmount()
+ it('clamps a trigger left of the viewport to the safe padding', async () => {
+ setViewportWidth(320)
+ mockTriggerRect(-20, 80)
+
+ const dropdown = await openSelect()
+
+ expect(dropdown).not.toBeNull()
+ expect(dropdown?.style.left).toBe('8px')
+ expect(dropdown?.style.minWidth).toBe('200px')
+ expect(dropdown?.style.maxWidth).toBe('304px')
+ })
+
+ it('clamps an offscreen-right trigger position to the viewport boundary', async () => {
+ setViewportWidth(320)
+ mockTriggerRect(400, 80)
+
+ const dropdown = await openSelect()
+
+ expect(dropdown).not.toBeNull()
+ expect(dropdown?.style.left).toBe('312px')
+ expect(dropdown?.style.minWidth).toBe('0px')
+ expect(dropdown?.style.maxWidth).toBe('0px')
})
})