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') }) })