mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
Merge pull request #4924 from Cynicismcart/fix/group-description-wrapping
修复分组描述换行与下拉框溢出
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
<!-- Row 2: description with top spacing -->
|
||||
<span
|
||||
v-if="description"
|
||||
class="mt-1.5 w-full text-left text-xs leading-relaxed text-gray-500 dark:text-gray-400 line-clamp-2"
|
||||
class="mt-1.5 w-full whitespace-pre-line [overflow-wrap:anywhere] text-left text-xs leading-relaxed text-gray-500 dark:text-gray-400 line-clamp-3"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
|
||||
@@ -184,6 +184,8 @@ const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const optionsListRef = ref<HTMLElement | null>(null)
|
||||
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
|
||||
const triggerRect = ref<DOMRect | null>(null)
|
||||
const dropdownViewportPadding = 8
|
||||
const dropdownMinimumWidth = 200
|
||||
|
||||
// i18n placeholders
|
||||
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
|
||||
@@ -200,10 +202,19 @@ const dropdownStyle = computed(() => {
|
||||
if (!triggerRect.value) return {}
|
||||
|
||||
const rect = triggerRect.value
|
||||
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<string, string> = {
|
||||
position: 'fixed',
|
||||
left: `${rect.left}px`,
|
||||
minWidth: `${rect.width}px`,
|
||||
left: `${left}px`,
|
||||
minWidth: `${minWidth}px`,
|
||||
maxWidth: `${availableWidth}px`,
|
||||
zIndex: '100000020'
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof import('vue-i18n')>('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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
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<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}
|
||||
})
|
||||
|
||||
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<HTMLElement>('.select-dropdown-portal')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
unmountWrapper?.()
|
||||
unmountWrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
setViewportWidth(originalInnerWidth)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Select dropdown viewport constraints', () => {
|
||||
it('preserves the existing 200px minimum width when space is available', async () => {
|
||||
setViewportWidth(1024)
|
||||
mockTriggerRect(20, 80)
|
||||
|
||||
const dropdown = await openSelect()
|
||||
|
||||
expect(dropdown).not.toBeNull()
|
||||
expect(dropdown?.style.left).toBe('20px')
|
||||
expect(dropdown?.style.minWidth).toBe('200px')
|
||||
expect(dropdown?.style.maxWidth).toBe('996px')
|
||||
})
|
||||
|
||||
it('shrinks the minimum width to fit near the right viewport edge', async () => {
|
||||
setViewportWidth(320)
|
||||
mockTriggerRect(220, 80)
|
||||
|
||||
const dropdown = await openSelect()
|
||||
|
||||
expect(dropdown).not.toBeNull()
|
||||
expect(dropdown?.style.left).toBe('220px')
|
||||
expect(dropdown?.style.minWidth).toBe('92px')
|
||||
expect(dropdown?.style.maxWidth).toBe('92px')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user