fix(ui): prevent dropdown clipping with fixed positioning

Automatically use fixed dropdown strategy for TagsSelect inside modals
Dynamically calculate fixed coordinates for Pokemon fetch results dropdown
This commit is contained in:
2026-05-03 11:38:32 +08:00
parent 3ca66d7124
commit accd6f98cf
3 changed files with 92 additions and 12 deletions

View File

@@ -38,8 +38,7 @@ const props = withDefaults(
multiple: true,
max: 0,
allowCreate: false,
creating: false,
dropdownStrategy: 'absolute'
creating: false
}
);
@@ -57,6 +56,7 @@ const search = ref('');
const activeIndex = ref(-1);
const dropdownStyle = ref<CSSProperties>({});
const dropdownPlacement = ref<'top' | 'bottom'>('bottom');
const isInsideModal = ref(false);
let positionFrame = 0;
const optionRows = computed(() =>
@@ -111,7 +111,8 @@ const candidateRows = computed<CandidateRow[]>(() => {
});
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
const activeDescendant = computed(() => activeCandidate.value?.id);
const usesFixedDropdown = computed(() => props.dropdownStrategy === 'fixed');
const resolvedDropdownStrategy = computed<DropdownStrategy>(() => props.dropdownStrategy ?? (isInsideModal.value ? 'fixed' : 'absolute'));
const usesFixedDropdown = computed(() => resolvedDropdownStrategy.value === 'fixed');
function setDefaultActiveIndex() {
const keyword = createName.value.toLowerCase();
@@ -311,6 +312,7 @@ function removePositionListeners() {
}
onMounted(() => {
isInsideModal.value = root.value?.closest('.modal') !== null;
document.addEventListener('pointerdown', onDocumentPointerDown);
});

View File

@@ -796,12 +796,12 @@ button:disabled,
}
.pokemon-fetch-results {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
z-index: 35;
max-height: 260px;
position: fixed;
top: auto;
right: auto;
left: auto;
z-index: 80;
max-height: var(--pokemon-fetch-results-max-height, 260px);
overflow-y: auto;
display: grid;
gap: 4px;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import ImageUploadField from '../components/ImageUploadField.vue';
@@ -49,8 +49,10 @@ const imageBusy = ref(false);
const fetchOptionsLoading = ref(false);
const fetchOptionsOpen = ref(false);
const message = ref('');
const fetchInput = ref<HTMLInputElement | null>(null);
const fetchIdentifier = ref('');
const fetchOptions = ref<PokemonFetchOption[]>([]);
const fetchResultsStyle = ref<CSSProperties>({});
const imageOptions = ref<PokemonImage[]>([]);
const currentPokemonImage = ref<PokemonImage | null>(null);
const imageHistory = ref<EntityImageUpload[]>([]);
@@ -59,6 +61,7 @@ const activeEditTab = ref('basic');
const heightUnit = ref<'imperial' | 'metric'>('imperial');
const weightUnit = ref<'imperial' | 'metric'>('imperial');
let fetchOptionsController: AbortController | null = null;
let fetchPositionFrame = 0;
function defaultPokemonStats(): PokemonStats {
return {
@@ -373,17 +376,80 @@ function refreshFetchOptions() {
void loadFetchOptions();
}
function updateFetchResultsPosition() {
if (!fetchInput.value) {
fetchResultsStyle.value = {};
return;
}
const viewportPadding = 12;
const dropdownGap = 6;
const inputRect = fetchInput.value.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.min(inputRect.width, viewportWidth - viewportPadding * 2);
const left = Math.min(Math.max(inputRect.left, viewportPadding), viewportWidth - width - viewportPadding);
const spaceBelow = viewportHeight - inputRect.bottom - viewportPadding - dropdownGap;
const spaceAbove = inputRect.top - viewportPadding - dropdownGap;
const placeAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
const maxHeight = Math.max(96, Math.min(260, placeAbove ? spaceAbove : spaceBelow));
const nextStyle = {
left: `${left}px`,
width: `${width}px`,
'--pokemon-fetch-results-max-height': `${maxHeight}px`
} as CSSProperties;
fetchResultsStyle.value = placeAbove
? { ...nextStyle, bottom: `${viewportHeight - inputRect.top + dropdownGap}px` }
: { ...nextStyle, top: `${inputRect.bottom + dropdownGap}px` };
}
function scheduleFetchResultsPositionUpdate() {
if (!fetchOptionsOpen.value || fetchPositionFrame) {
return;
}
fetchPositionFrame = window.requestAnimationFrame(() => {
fetchPositionFrame = 0;
updateFetchResultsPosition();
});
}
function addFetchPositionListeners() {
window.addEventListener('resize', scheduleFetchResultsPositionUpdate);
window.addEventListener('scroll', scheduleFetchResultsPositionUpdate, true);
}
function removeFetchPositionListeners() {
window.removeEventListener('resize', scheduleFetchResultsPositionUpdate);
window.removeEventListener('scroll', scheduleFetchResultsPositionUpdate, true);
if (fetchPositionFrame) {
window.cancelAnimationFrame(fetchPositionFrame);
fetchPositionFrame = 0;
}
}
function positionFetchResultsAfterOpen() {
updateFetchResultsPosition();
addFetchPositionListeners();
void nextTick(updateFetchResultsPosition);
}
function openFetchOptions() {
if (!canFetchPokemon.value) {
return;
}
fetchOptionsOpen.value = true;
positionFetchResultsAfterOpen();
refreshFetchOptions();
}
function closeFetchOptions() {
fetchOptionsOpen.value = false;
fetchResultsStyle.value = {};
removeFetchPositionListeners();
cancelFetchOptionsRequest();
}
@@ -393,6 +459,7 @@ function handleFetchIdentifierInput() {
}
fetchOptionsOpen.value = true;
positionFetchResultsAfterOpen();
}
function closeFetchOptionsAfterBlur() {
@@ -607,7 +674,10 @@ onMounted(() => {
void loadEditor();
});
onBeforeUnmount(cancelFetchOptionsRequest);
onBeforeUnmount(() => {
cancelFetchOptionsRequest();
removeFetchPositionListeners();
});
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
watch(fetchIdentifier, refreshFetchOptions);
@@ -625,6 +695,7 @@ watch(fetchIdentifier, refreshFetchOptions);
<label for="pokemon-fetch-identifier">{{ t('pages.pokemon.fetchIdentifier') }}</label>
<input
id="pokemon-fetch-identifier"
ref="fetchInput"
v-model="fetchIdentifier"
type="search"
:placeholder="t('pages.pokemon.fetchIdentifierPlaceholder')"
@@ -638,7 +709,14 @@ watch(fetchIdentifier, refreshFetchOptions);
@keydown.escape.stop="closeFetchOptions"
@keydown.enter.prevent="fetchPokemonFromInput"
/>
<div v-if="fetchOptionsOpen" id="pokemon-fetch-results" class="pokemon-fetch-results" role="listbox" :aria-label="t('pages.pokemon.fetchResults')">
<div
v-if="fetchOptionsOpen"
id="pokemon-fetch-results"
class="pokemon-fetch-results"
:style="fetchResultsStyle"
role="listbox"
:aria-label="t('pages.pokemon.fetchResults')"
>
<p v-if="fetchOptionsLoading" class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchSearching') }}</p>
<template v-else-if="fetchOptions.length">
<button