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:
@@ -38,8 +38,7 @@ const props = withDefaults(
|
|||||||
multiple: true,
|
multiple: true,
|
||||||
max: 0,
|
max: 0,
|
||||||
allowCreate: false,
|
allowCreate: false,
|
||||||
creating: false,
|
creating: false
|
||||||
dropdownStrategy: 'absolute'
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -57,6 +56,7 @@ const search = ref('');
|
|||||||
const activeIndex = ref(-1);
|
const activeIndex = ref(-1);
|
||||||
const dropdownStyle = ref<CSSProperties>({});
|
const dropdownStyle = ref<CSSProperties>({});
|
||||||
const dropdownPlacement = ref<'top' | 'bottom'>('bottom');
|
const dropdownPlacement = ref<'top' | 'bottom'>('bottom');
|
||||||
|
const isInsideModal = ref(false);
|
||||||
let positionFrame = 0;
|
let positionFrame = 0;
|
||||||
|
|
||||||
const optionRows = computed(() =>
|
const optionRows = computed(() =>
|
||||||
@@ -111,7 +111,8 @@ const candidateRows = computed<CandidateRow[]>(() => {
|
|||||||
});
|
});
|
||||||
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
|
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
|
||||||
const activeDescendant = computed(() => activeCandidate.value?.id);
|
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() {
|
function setDefaultActiveIndex() {
|
||||||
const keyword = createName.value.toLowerCase();
|
const keyword = createName.value.toLowerCase();
|
||||||
@@ -311,6 +312,7 @@ function removePositionListeners() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
isInsideModal.value = root.value?.closest('.modal') !== null;
|
||||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -796,12 +796,12 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pokemon-fetch-results {
|
.pokemon-fetch-results {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: calc(100% + 6px);
|
top: auto;
|
||||||
right: 0;
|
right: auto;
|
||||||
left: 0;
|
left: auto;
|
||||||
z-index: 35;
|
z-index: 80;
|
||||||
max-height: 260px;
|
max-height: var(--pokemon-fetch-results-max-height, 260px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
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 { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import ImageUploadField from '../components/ImageUploadField.vue';
|
import ImageUploadField from '../components/ImageUploadField.vue';
|
||||||
@@ -49,8 +49,10 @@ const imageBusy = ref(false);
|
|||||||
const fetchOptionsLoading = ref(false);
|
const fetchOptionsLoading = ref(false);
|
||||||
const fetchOptionsOpen = ref(false);
|
const fetchOptionsOpen = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
|
const fetchInput = ref<HTMLInputElement | null>(null);
|
||||||
const fetchIdentifier = ref('');
|
const fetchIdentifier = ref('');
|
||||||
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
||||||
|
const fetchResultsStyle = ref<CSSProperties>({});
|
||||||
const imageOptions = ref<PokemonImage[]>([]);
|
const imageOptions = ref<PokemonImage[]>([]);
|
||||||
const currentPokemonImage = ref<PokemonImage | null>(null);
|
const currentPokemonImage = ref<PokemonImage | null>(null);
|
||||||
const imageHistory = ref<EntityImageUpload[]>([]);
|
const imageHistory = ref<EntityImageUpload[]>([]);
|
||||||
@@ -59,6 +61,7 @@ const activeEditTab = ref('basic');
|
|||||||
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||||
const weightUnit = ref<'imperial' | 'metric'>('imperial');
|
const weightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||||
let fetchOptionsController: AbortController | null = null;
|
let fetchOptionsController: AbortController | null = null;
|
||||||
|
let fetchPositionFrame = 0;
|
||||||
|
|
||||||
function defaultPokemonStats(): PokemonStats {
|
function defaultPokemonStats(): PokemonStats {
|
||||||
return {
|
return {
|
||||||
@@ -373,17 +376,80 @@ function refreshFetchOptions() {
|
|||||||
void loadFetchOptions();
|
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() {
|
function openFetchOptions() {
|
||||||
if (!canFetchPokemon.value) {
|
if (!canFetchPokemon.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchOptionsOpen.value = true;
|
fetchOptionsOpen.value = true;
|
||||||
|
positionFetchResultsAfterOpen();
|
||||||
refreshFetchOptions();
|
refreshFetchOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeFetchOptions() {
|
function closeFetchOptions() {
|
||||||
fetchOptionsOpen.value = false;
|
fetchOptionsOpen.value = false;
|
||||||
|
fetchResultsStyle.value = {};
|
||||||
|
removeFetchPositionListeners();
|
||||||
cancelFetchOptionsRequest();
|
cancelFetchOptionsRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +459,7 @@ function handleFetchIdentifierInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchOptionsOpen.value = true;
|
fetchOptionsOpen.value = true;
|
||||||
|
positionFetchResultsAfterOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeFetchOptionsAfterBlur() {
|
function closeFetchOptionsAfterBlur() {
|
||||||
@@ -607,7 +674,10 @@ onMounted(() => {
|
|||||||
void loadEditor();
|
void loadEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(cancelFetchOptionsRequest);
|
onBeforeUnmount(() => {
|
||||||
|
cancelFetchOptionsRequest();
|
||||||
|
removeFetchPositionListeners();
|
||||||
|
});
|
||||||
|
|
||||||
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||||
watch(fetchIdentifier, refreshFetchOptions);
|
watch(fetchIdentifier, refreshFetchOptions);
|
||||||
@@ -625,6 +695,7 @@ watch(fetchIdentifier, refreshFetchOptions);
|
|||||||
<label for="pokemon-fetch-identifier">{{ t('pages.pokemon.fetchIdentifier') }}</label>
|
<label for="pokemon-fetch-identifier">{{ t('pages.pokemon.fetchIdentifier') }}</label>
|
||||||
<input
|
<input
|
||||||
id="pokemon-fetch-identifier"
|
id="pokemon-fetch-identifier"
|
||||||
|
ref="fetchInput"
|
||||||
v-model="fetchIdentifier"
|
v-model="fetchIdentifier"
|
||||||
type="search"
|
type="search"
|
||||||
:placeholder="t('pages.pokemon.fetchIdentifierPlaceholder')"
|
:placeholder="t('pages.pokemon.fetchIdentifierPlaceholder')"
|
||||||
@@ -638,7 +709,14 @@ watch(fetchIdentifier, refreshFetchOptions);
|
|||||||
@keydown.escape.stop="closeFetchOptions"
|
@keydown.escape.stop="closeFetchOptions"
|
||||||
@keydown.enter.prevent="fetchPokemonFromInput"
|
@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>
|
<p v-if="fetchOptionsLoading" class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchSearching') }}</p>
|
||||||
<template v-else-if="fetchOptions.length">
|
<template v-else-if="fetchOptions.length">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user