Demo images / Build & push demo images (push) Failing after 33s
BaseModal becomes a flex column: title row and the new #header slot (tabs) stay fixed at the top, #footer (action buttons) fixed at the bottom, only the default slot scrolls (default max-h 90vh unless the caller sets one). Wallet Settings migrates: tabs pinned, four duplicated in-pane Close rows collapse into one pinned footer that carries the active tab's primary action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
112 lines
3.4 KiB
Vue
112 lines
3.4 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<Transition name="modal">
|
|
<div
|
|
v-if="show"
|
|
class="fixed inset-0 flex items-center justify-center p-4"
|
|
:class="zClass"
|
|
@click.self="close"
|
|
>
|
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
|
<!-- Column layout (2026-07-22 modal contract): title row and the
|
|
optional #header slot (tabs) stay pinned at the top, the #footer
|
|
slot (action buttons) stays pinned at the bottom, and ONLY the
|
|
default slot scrolls. Callers that previously made the whole
|
|
card scroll via contentClass keep working — the inner region
|
|
simply never lets the card overflow. -->
|
|
<div
|
|
ref="modalRef"
|
|
class="glass-card p-6 w-full relative z-10 flex flex-col"
|
|
:class="[maxWidth, contentClass, defaultMaxH]"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
@click.stop
|
|
>
|
|
<div class="flex items-start justify-between gap-4 mb-4 shrink-0">
|
|
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
|
|
<button
|
|
@click="close"
|
|
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
|
aria-label="Close"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div v-if="$slots.header" class="shrink-0">
|
|
<slot name="header" />
|
|
</div>
|
|
<div class="flex-1 min-h-0 overflow-y-auto">
|
|
<slot />
|
|
</div>
|
|
<div v-if="$slots.footer" class="shrink-0 pt-4">
|
|
<slot name="footer" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
|
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
show: boolean
|
|
title: string
|
|
maxWidth?: string
|
|
zIndex?: string
|
|
contentClass?: string
|
|
}>(), {
|
|
maxWidth: 'max-w-md',
|
|
zIndex: 'z-[3000]',
|
|
contentClass: '',
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
}>()
|
|
|
|
const modalRef = ref<HTMLElement | null>(null)
|
|
|
|
const zClass = computed(() => props.zIndex)
|
|
// The pinned-footer layout needs a height bound or tall content pushes the
|
|
// footer off-screen anyway. Callers that set their own max-h (e.g. the
|
|
// Transactions modal's visual-viewport calc on mobile) keep authority —
|
|
// adding a second max-h class would make the CSS winner order-dependent.
|
|
const defaultMaxH = computed(() =>
|
|
props.contentClass.includes('max-h-') ? '' : 'max-h-[90vh]'
|
|
)
|
|
|
|
function close() {
|
|
emit('close')
|
|
}
|
|
|
|
useModalKeyboard(modalRef, computed(() => props.show), close)
|
|
useBodyScrollLock(computed(() => props.show))
|
|
</script>
|
|
|
|
<style scoped>
|
|
.modal-enter-active,
|
|
.modal-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
.modal-enter-from,
|
|
.modal-leave-to {
|
|
opacity: 0;
|
|
}
|
|
.modal-enter-active .glass-card,
|
|
.modal-leave-active .glass-card {
|
|
transition: transform 0.2s ease;
|
|
}
|
|
.modal-enter-from .glass-card {
|
|
transform: scale(0.95);
|
|
}
|
|
.modal-leave-to .glass-card {
|
|
transform: scale(0.95);
|
|
}
|
|
</style>
|