Anonymous
Как сохранить кнопки всегда внизу модала Ion Sheet?
Сообщение
Anonymous » 17 янв 2026, 21:49
Я использую модальное окно ion с точками останова, поэтому у него есть дескриптор, и я могу изменить его размер.
Хорошее состояние #1
Хорошее состояние #2
Здесь, на этой картинке, вы можете увидеть правильную вещь:
А также, когда я нажимаю «Отправить», все правильно с недопустимыми сообщениями:
Но проблема в том, что когда я поднимаю ящик так, что он занимает весь экран, кнопка находится не внизу, как показано здесь.
Плохое состояние
Измерения, которые я использую
Я хочу, чтобы кнопка была внизу, независимо от размера ящика, но я не могу этого сделать.
m-add-project.vue
Код: Выделить всё
{{ $t('projectModal.addNewProject') }}
{{ $t('projectModal.projectName')
}}
{{ $t('projectModal.category')
}}
{{ $t('projectModal.selectDate')
}}
{{ err.$message }}
{{ $t('projectModal.budgetUSD')
}}
{{ err.$message }}
{{ $t('projectModal.cancel') }}
{{ $t('projectModal.createProject') }}
import { ref, watch, computed } from 'vue'
import { required, minLength, maxLength, minValue, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import XIcon from '@/plugins/app@projects/components/add-new-project/assets/x-icon.vue'
import DatePicker from 'primevue/datepicker'
import BudgetIcon from '@/plugins/app@projects/components/add-new-project/assets/budget-icon.vue'
import InputNumber from 'primevue/inputnumber'
import { useProjectsManagement } from '@/plugins/app@projects/composables/projects-management.composable'
import AInviteSelect from '@/plugins/app@projects/components/add-new-project/components/a-invite-select.vue'
import AUploadIcon from '@/plugins/app@projects/components/add-new-project/components/a-upload-icon.vue'
import { projectCategories } from '@/plugins/app@projects/composables/projects-management.composable'
import type { Project } from '@/plugins/app@projects/types/project.types'
import { getGlobalProperties } from '@wezeo/plugins'
import { useIsMobile } from '@/plugins/app/_composables/is-mobile.composable'
const emit = defineEmits(['closeModal', 'recalc-modal'])
const { $gp } = getGlobalProperties()
const uploadedFileName = ref('')
const { createProject } = useProjectsManagement()
const { isMobile } = useIsMobile()
const getInitialValues = () => ({
name: '',
category: null,
dueDate: null,
budget: null,
members: [],
uploadedImageUrl: ''
})
const fields = ref(getInitialValues())
const rules = {
name: {
required: helpers.withMessage($gp.$t('validation.required'), required),
minLength: helpers.withMessage(
({ $params }) => $gp.$t('validation.minLength', { min: $params.min }),
minLength(5)
),
maxLength: helpers.withMessage(
({ $params }) => $gp.$t('validation.maxLength', { max: $params.max }),
maxLength(10)
)
},
category: {
required: helpers.withMessage($gp.$t('validation.required'), required)
},
dueDate: {
required: helpers.withMessage($gp.$t('validation.required'), required),
notInFuture: helpers.withMessage($gp.$t('validation.notInFuture'), value => !value || value
projectCategories.map(option => ({
...option,
value: $gp.$t(option.value)
}))
)
function emitClose() {
emit('closeModal')
resetValues()
}
function resetValues() {
fields.value = getInitialValues()
v$.value.$reset()
}
function saveProject(newProject: Project) {
createProject(newProject)
$gp.$toast.success('Project was successfully created!', 'bottom', 3000)
}
function createProjectObject(): Project {
return {
title: fields.value.name,
category: fields.value.category || '',
date: fields.value.dueDate ? formatDateForProject(fields.value.dueDate) : '',
budget: `$${Number(fields.value.budget).toLocaleString('de-DE')}`,
members: fields.value.members.map((member: any) => member.name),
status: 'started',
completedTasks: 0,
totalTasks: 0,
icon: fields.value.uploadedImageUrl || null
}
}
async function confirmAction(): Promise {
try {
await $gp.$alert.confirm('Do you want to create this project?')
return true
} catch (e) {
return false
}
}
async function submitForm() {
const isValid = await v$.value.$validate()
if (!isValid) return
const confirmed = await confirmAction()
if (!confirmed) return
const project = createProjectObject()
saveProject(project)
emitClose()
}
function formatDateForProject(date: Date): string {
const day = date.getDate().toString().padStart(2, '0');
const monthIndex = date.getMonth();
const year = date.getFullYear();
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
const monthName = months[monthIndex];
return `${day} ${monthName} ${year}`;
}
watch(
() => v$.value.$errors.map(e => e.$message).join(','),
async () => {
emit('recalc-modal')
}
)
.w-alert-modal .ion-page {
border: 1px solid var(--ion-color-neutral-grey);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
}
.project-datepicker:deep(.p-inputtext::placeholder) {
color: var(--p-slate-500);
}
:deep(.p-datepicker) {
border-radius: 8px;
}
.project-budget:deep(.p-inputtext::placeholder) {
color: var(--p-slate-500);
}
.project-datepicker :deep(.p-datepicker-input-icon-container .p-datepicker-input-icon) {
width: 16px;
height: 19px;
min-width: 16px;
min-height: 19px;
max-width: 16px;
max-height: 19px;
}
:deep(.p-inputtext) {
font-size: 14px;
line-height: 21px;
}
:deep(.w-input-wrapper.custom-border-grey) {
border: 1px solid var(--ion-color-neutral-grey);
}
:deep(.p-inputtext) {
border: 1px solid var(--ion-color-neutral-grey);
}
:deep(.p-inputtext) {
border: 1px solid var(--ion-color-neutral-grey);
box-shadow: none;
}
m-Response-Modal:
Код: Выделить всё
import { ref, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useIsMobile } from '@/plugins/app/_composables/is-mobile.composable'
const isOpen = ref(false)
const { isMobile } = useIsMobile()
const preMeasureRef = ref(null)
const measuredHeight = ref(650)
const computedBreakpoints = ref([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1])
const computedInitialBreakpoint = ref(0.5)
const modalRef = ref(null)
let resizeObserver
const isAvailable = ref(true)
onMounted(() => {
if (isMobile.value && preMeasureRef.value) {
resizeObserver = new ResizeObserver(() => {
const contentHeight = preMeasureRef.value?.offsetHeight || 650
measuredHeight.value = contentHeight
})
resizeObserver.observe(preMeasureRef.value)
}
})
onBeforeUnmount(() => {
if (resizeObserver && preMeasureRef.value) {
resizeObserver.unobserve(preMeasureRef.value)
}
})
async function openModal() {
await nextTick()
await recalcModal()
isOpen.value = true
}
function closeModal() {
isAvailable.value = false
isOpen.value = false;
modalRef.value = null;
setTimeout(() => {
isAvailable.value = true
}, 1)
}
async function recalcModal(validateActive: boolean = false) {
let contentHeight = preMeasureRef.value?.offsetHeight || 650
if (validateActive) {
contentHeight = contentHeight + 64
}
measuredHeight.value = contentHeight
const vh = window.innerHeight;
let fraction = Math.min(contentHeight / vh, 1);
let breakpoints = [...computedBreakpoints.value, fraction];
breakpoints = Array.from(new Set(breakpoints)).sort((a, b) => a - b);
computedBreakpoints.value = breakpoints;
computedInitialBreakpoint.value = fraction;
await nextTick();
await nextAnimationFrame();
if (isMobile.value && modalRef.value) {
modalRef.value.$el.setCurrentBreakpoint(fraction)
}
}
function nextAnimationFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
defineExpose({ openModal, closeModal, recalcModal })
@media (min-width: 640px) {
ion-modal.add-project-modal {
--height: auto;
}
}
И это пример в моем коде:
Хорошо, я хочу и нуждаюсь в том, чтобы независимо от размера или размера мобильного телефона и ящика кнопка всегда была внизу, даже на iPhone SE или iPhone 12 Pro.
Легко воспроизвести код:
Код: Выделить всё
This progression is locked
You need to complete level {{ modalData.previousLevel }} before accessing this.
Cancel
Level up
import { IonButton, IonContent, IonIcon, IonModal } from '@ionic/vue'
import { informationCircleOutline } from 'ionicons/icons'
import { useRouter } from 'vue-router'
const props = defineProps()
const emit = defineEmits()
const router = useRouter()
const handleLevelUp = () => {
emit('dismiss')
router.push({
path: `/warm-up-info-screen/${props.modalData.skillId}/${props.modalData.previousProgressionId}`,
query: { fromSkillId: props.modalData.skillId }
})
}
Хорошее состояние №1
Хорошее состояние №2
Поэтому я хочу, чтобы кнопки всегда находились в самый нижний, независимо от размера листа, если он равен 0,25 0,5 0,75.
Подробнее здесь:
https://stackoverflow.com/questions/796 ... heet-modal
1768675761
Anonymous
Я использую модальное окно ion с точками останова, поэтому у него есть дескриптор, и я могу изменить его размер. Хорошее состояние #1 Хорошее состояние #2 Здесь, на этой картинке, вы можете увидеть правильную вещь: А также, когда я нажимаю «Отправить», все правильно с недопустимыми сообщениями: [img]https://i.sstatic.net/TMyXGYhJ.png[/img] [img]https://i.sstatic.net/8cavmKTK.png[/img] Но проблема в том, что когда я поднимаю ящик так, что он занимает весь экран, кнопка находится не внизу, как показано здесь. Плохое состояние Измерения, которые я использую [img]https://i.sstatic.net/wjo3m4JY.png[/img] [img]https://i.sstatic.net/2fBIroeM.png[/img] Я хочу, чтобы кнопка была внизу, независимо от размера ящика, но я не могу этого сделать. [b]m-add-project.vue[/b] [code] {{ $t('projectModal.addNewProject') }} {{ $t('projectModal.projectName') }} {{ $t('projectModal.category') }} {{ $t('projectModal.selectDate') }} {{ err.$message }} {{ $t('projectModal.budgetUSD') }} {{ err.$message }} {{ $t('projectModal.cancel') }} {{ $t('projectModal.createProject') }} import { ref, watch, computed } from 'vue' import { required, minLength, maxLength, minValue, helpers } from '@vuelidate/validators' import useVuelidate from '@vuelidate/core' import XIcon from '@/plugins/app@projects/components/add-new-project/assets/x-icon.vue' import DatePicker from 'primevue/datepicker' import BudgetIcon from '@/plugins/app@projects/components/add-new-project/assets/budget-icon.vue' import InputNumber from 'primevue/inputnumber' import { useProjectsManagement } from '@/plugins/app@projects/composables/projects-management.composable' import AInviteSelect from '@/plugins/app@projects/components/add-new-project/components/a-invite-select.vue' import AUploadIcon from '@/plugins/app@projects/components/add-new-project/components/a-upload-icon.vue' import { projectCategories } from '@/plugins/app@projects/composables/projects-management.composable' import type { Project } from '@/plugins/app@projects/types/project.types' import { getGlobalProperties } from '@wezeo/plugins' import { useIsMobile } from '@/plugins/app/_composables/is-mobile.composable' const emit = defineEmits(['closeModal', 'recalc-modal']) const { $gp } = getGlobalProperties() const uploadedFileName = ref('') const { createProject } = useProjectsManagement() const { isMobile } = useIsMobile() const getInitialValues = () => ({ name: '', category: null, dueDate: null, budget: null, members: [], uploadedImageUrl: '' }) const fields = ref(getInitialValues()) const rules = { name: { required: helpers.withMessage($gp.$t('validation.required'), required), minLength: helpers.withMessage( ({ $params }) => $gp.$t('validation.minLength', { min: $params.min }), minLength(5) ), maxLength: helpers.withMessage( ({ $params }) => $gp.$t('validation.maxLength', { max: $params.max }), maxLength(10) ) }, category: { required: helpers.withMessage($gp.$t('validation.required'), required) }, dueDate: { required: helpers.withMessage($gp.$t('validation.required'), required), notInFuture: helpers.withMessage($gp.$t('validation.notInFuture'), value => !value || value projectCategories.map(option => ({ ...option, value: $gp.$t(option.value) })) ) function emitClose() { emit('closeModal') resetValues() } function resetValues() { fields.value = getInitialValues() v$.value.$reset() } function saveProject(newProject: Project) { createProject(newProject) $gp.$toast.success('Project was successfully created!', 'bottom', 3000) } function createProjectObject(): Project { return { title: fields.value.name, category: fields.value.category || '', date: fields.value.dueDate ? formatDateForProject(fields.value.dueDate) : '', budget: `$${Number(fields.value.budget).toLocaleString('de-DE')}`, members: fields.value.members.map((member: any) => member.name), status: 'started', completedTasks: 0, totalTasks: 0, icon: fields.value.uploadedImageUrl || null } } async function confirmAction(): Promise { try { await $gp.$alert.confirm('Do you want to create this project?') return true } catch (e) { return false } } async function submitForm() { const isValid = await v$.value.$validate() if (!isValid) return const confirmed = await confirmAction() if (!confirmed) return const project = createProjectObject() saveProject(project) emitClose() } function formatDateForProject(date: Date): string { const day = date.getDate().toString().padStart(2, '0'); const monthIndex = date.getMonth(); const year = date.getFullYear(); const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]; const monthName = months[monthIndex]; return `${day} ${monthName} ${year}`; } watch( () => v$.value.$errors.map(e => e.$message).join(','), async () => { emit('recalc-modal') } ) .w-alert-modal .ion-page { border: 1px solid var(--ion-color-neutral-grey); border-radius: 12px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); } .project-datepicker:deep(.p-inputtext::placeholder) { color: var(--p-slate-500); } :deep(.p-datepicker) { border-radius: 8px; } .project-budget:deep(.p-inputtext::placeholder) { color: var(--p-slate-500); } .project-datepicker :deep(.p-datepicker-input-icon-container .p-datepicker-input-icon) { width: 16px; height: 19px; min-width: 16px; min-height: 19px; max-width: 16px; max-height: 19px; } :deep(.p-inputtext) { font-size: 14px; line-height: 21px; } :deep(.w-input-wrapper.custom-border-grey) { border: 1px solid var(--ion-color-neutral-grey); } :deep(.p-inputtext) { border: 1px solid var(--ion-color-neutral-grey); } :deep(.p-inputtext) { border: 1px solid var(--ion-color-neutral-grey); box-shadow: none; } [/code] m-Response-Modal: [code] import { ref, nextTick, onMounted, onBeforeUnmount } from 'vue' import { useIsMobile } from '@/plugins/app/_composables/is-mobile.composable' const isOpen = ref(false) const { isMobile } = useIsMobile() const preMeasureRef = ref(null) const measuredHeight = ref(650) const computedBreakpoints = ref([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]) const computedInitialBreakpoint = ref(0.5) const modalRef = ref(null) let resizeObserver const isAvailable = ref(true) onMounted(() => { if (isMobile.value && preMeasureRef.value) { resizeObserver = new ResizeObserver(() => { const contentHeight = preMeasureRef.value?.offsetHeight || 650 measuredHeight.value = contentHeight }) resizeObserver.observe(preMeasureRef.value) } }) onBeforeUnmount(() => { if (resizeObserver && preMeasureRef.value) { resizeObserver.unobserve(preMeasureRef.value) } }) async function openModal() { await nextTick() await recalcModal() isOpen.value = true } function closeModal() { isAvailable.value = false isOpen.value = false; modalRef.value = null; setTimeout(() => { isAvailable.value = true }, 1) } async function recalcModal(validateActive: boolean = false) { let contentHeight = preMeasureRef.value?.offsetHeight || 650 if (validateActive) { contentHeight = contentHeight + 64 } measuredHeight.value = contentHeight const vh = window.innerHeight; let fraction = Math.min(contentHeight / vh, 1); let breakpoints = [...computedBreakpoints.value, fraction]; breakpoints = Array.from(new Set(breakpoints)).sort((a, b) => a - b); computedBreakpoints.value = breakpoints; computedInitialBreakpoint.value = fraction; await nextTick(); await nextAnimationFrame(); if (isMobile.value && modalRef.value) { modalRef.value.$el.setCurrentBreakpoint(fraction) } } function nextAnimationFrame() { return new Promise(resolve => requestAnimationFrame(resolve)); } defineExpose({ openModal, closeModal, recalcModal }) @media (min-width: 640px) { ion-modal.add-project-modal { --height: auto; } } [/code] И это пример в моем коде: [code] [/code] Хорошо, я хочу и нуждаюсь в том, чтобы независимо от размера или размера мобильного телефона и ящика кнопка всегда была внизу, даже на iPhone SE или iPhone 12 Pro. Легко воспроизвести код: [code] This progression is locked You need to complete level {{ modalData.previousLevel }} before accessing this. Cancel Level up import { IonButton, IonContent, IonIcon, IonModal } from '@ionic/vue' import { informationCircleOutline } from 'ionicons/icons' import { useRouter } from 'vue-router' const props = defineProps() const emit = defineEmits() const router = useRouter() const handleLevelUp = () => { emit('dismiss') router.push({ path: `/warm-up-info-screen/${props.modalData.skillId}/${props.modalData.previousProgressionId}`, query: { fromSkillId: props.modalData.skillId } }) } [/code] Хорошее состояние №1 Хорошее состояние №2 [img]https://i.sstatic.net/WiVcqtRw.png[/img] [img]https://i.sstatic.net/Fy5R3f4V.png[/img] Поэтому я хочу, чтобы кнопки всегда находились в самый нижний, независимо от размера листа, если он равен 0,25 0,5 0,75. Подробнее здесь: [url]https://stackoverflow.com/questions/79699078/how-to-keep-buttons-always-at-the-bottom-of-the-ion-sheet-modal[/url]