fix(frontend): 完善下拉框视口边界处理

This commit is contained in:
Cynicismcart
2026-07-27 00:41:40 +08:00
parent 005a5d2a37
commit 78f78947f1
2 changed files with 93 additions and 35 deletions
+9 -3
View File
@@ -185,6 +185,7 @@ 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'))
@@ -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<string, string> = {
position: 'fixed',
left: `${left}px`,
@@ -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<HTMLElement>('.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<HTMLElement>('.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')
})
})