Files
archy/neode-ui/src/components/BaseModal.vue
T
archipelagoandClaude Opus 5 204d4523da
Demo images / Build & push demo images (push) Successful in 3m20s
fix(ui): a modal must not outlive the screen that raised it
Clicking "Open a channel" or "Setup Guide" navigated correctly but left the
wallet's send/receive modal floating over the destination. The Lightning modal
itself did close — the parent did not. Tab views are KeepAlive'd, so
navigating deactivates the owner rather than unmounting it, and its Teleported
modal keeps rendering.

BaseModal now emits close on any route change while shown, fixing the class in
one place rather than per button. Every modal here is a transient dialog; none
should survive navigation. Two tests pin it, including that a hidden modal
stays quiet.

Also fixes a test-only regression from fa26c5fc: useLightningRequired()
resolved the Pinia store at composable-call time, so merely having the gate in
SendBitcoinModal made it unmountable without an active Pinia (PaidTick mounts
it bare). The store is now resolved lazily inside the function that needs it —
a gate should never be what breaks a component's ability to mount. That one
shipped because I verified fa26c5fc with targeted tests and a build but had
not re-run the full suite since 5718179e.

Verified: full suite 103 files / 827 tests green; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:12:49 -04:00

130 lines
4.1 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, watch } from 'vue'
import { useRoute } from 'vue-router'
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)
// A modal must not outlive the screen that raised it. Tab views are
// KeepAlive'd, so navigating away deactivates the owner rather than
// unmounting it, and a Teleported modal keeps floating over the destination
// (seen with the Lightning "Open a channel" / "Setup Guide" actions, which
// route away from inside the wallet's own modal). Closing on any route change
// is the general fix — every modal here is a transient dialog.
//
// `useRoute()` returns undefined when no router is installed (component
// tests mount BaseModal bare), so the getter is optional-chained.
const route = useRoute()
watch(
() => route?.fullPath,
(to, from) => {
if (to !== from && props.show) emit('close')
},
)
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>