mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(frontend): 完善下拉框视口边界处理
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user