Files
Starlight_Launcher/packages/ui/src/components/modal/NewModal.vue
T
Disy bc904065c3
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
feat: complete hosted mod sync and launcher interface updates
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
2026-09-15 19:06:56 +08:00

519 lines
13 KiB
Vue

<template>
<Teleport to="body">
<div v-if="open" class="modal-root">
<div
:class="{ shown: visible }"
class="tauri-overlay"
data-tauri-drag-region
@click="() => (closeOnClickOutside && closable ? hide() : {})"
/>
<div
:class="[
'modal-overlay',
{
shown: visible,
noblur: effectiveNoblur,
},
computedFade,
]"
@click="() => (closeOnClickOutside && closable ? hide() : {})"
/>
<div class="modal-container" :class="{ shown: visible }">
<div
ref="modalBodyRef"
role="dialog"
aria-modal="true"
:aria-labelledby="headerId"
class="modal-body flex flex-col bg-surface-3 rounded-2xl border border-solid border-surface-5"
v-bind="$attrs"
@keydown="handleKeyDown"
>
<div
v-if="!hideHeader"
data-tauri-drag-region
class="grid grid-cols-[auto_min-content] items-center gap-4 p-6 border-solid border-0 border-b-[1px] border-surface-5 max-w-full"
>
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
<slot name="title">
<span v-if="header" :id="headerId" class="text-2xl font-semibold text-contrast">
{{ header }}
</span>
</slot>
</div>
<ButtonStyled v-if="closable" circular>
<button
v-tooltip="closeLabel"
:aria-label="closeLabel"
:disabled="disableClose"
@click="hide"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<ButtonStyled v-if="props.mergeHeader && closable" circular>
<button
v-tooltip="closeLabel"
class="absolute top-4 right-4 z-10"
:aria-label="closeLabel"
:disabled="disableClose"
@click="hide"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
<div v-if="scrollable" class="relative flex-1 min-h-0 flex flex-col">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="scrollContainer"
data-modal-content
:class="[
'flex-1 min-h-0',
props.noPadding ? '' : 'overflow-y-auto p-6 !pb-1 sm:pb-6',
{ 'pt-12': props.mergeHeader && closable && !props.noPadding },
]"
:style="props.noPadding ? {} : { maxHeight: maxContentHeight }"
@scroll="checkScrollState"
>
<slot> You just lost the game.</slot>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<div
v-else
data-modal-content
:class="[
'min-h-0',
props.noPadding ? '' : 'overflow-y-auto p-6',
{ 'pt-12': props.mergeHeader && closable && !props.noPadding },
]"
>
<slot> You just lost the game.</slot>
</div>
<div
v-if="$slots.actions"
:class="{ 'pt-4 border-0 border-t border-solid border-surface-5': actionsDivider }"
class="p-4"
>
<slot name="actions" />
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { useVIntl } from '../../composables/i18n'
import { useModalStack } from '../../composables/modal-stack'
import { useScrollIndicator } from '../../composables/scroll-indicator'
import { injectModalBehavior } from '../../providers'
import { commonMessages } from '../../utils/common-messages'
import ButtonStyled from '../base/ButtonStyled.vue'
import { createModalTransition } from './modal-transition'
const { formatMessage } = useVIntl()
const modalBehavior = injectModalBehavior(null)
const {
push: pushModal,
pop: popModal,
isTopmost: isTopmostModal,
stackSize: modalStackSize,
} = useModalStack()
const props = withDefaults(
defineProps<{
noblur?: boolean
closable?: boolean
/** @deprecated Use `fade="danger"` instead */
danger?: boolean
fade?: 'standard' | 'warning' | 'danger'
closeOnEsc?: boolean
closeOnClickOutside?: boolean
warnOnClose?: boolean
header?: string
hideHeader?: boolean
onHide?: () => void
onShow?: () => void
mergeHeader?: boolean
scrollable?: boolean
maxContentHeight?: string
/** Removes padding from the content area. Useful for edge-to-edge layouts. */
noPadding?: boolean
/** Max width for the modal (e.g., '460px', '600px'). Defaults to '60rem'. */
maxWidth?: string
/** Width for the modal body (e.g., '460px', '600px'). */
width?: string
/** Disables all close actions (close button, ESC key, click outside). */
disableClose?: boolean
actionsDivider?: boolean
onAfterHide?: () => void
}>(),
{
type: true,
noblur: undefined,
closable: true,
danger: false,
fade: undefined,
closeOnClickOutside: false,
closeOnEsc: true,
warnOnClose: false,
header: undefined,
hideHeader: false,
onHide: () => {},
onShow: () => {},
mergeHeader: false,
// TODO: migrate all modals to use scrollable and remove this prop
scrollable: false,
maxContentHeight: '70vh',
noPadding: false,
maxWidth: undefined,
width: undefined,
disableClose: false,
actionsDivider: false,
},
)
const effectiveNoblur = computed(() => props.noblur ?? modalBehavior?.noblur.value ?? false)
const computedFade = computed(() => {
if (props.fade) return props.fade
if (props.danger) return 'danger'
return 'standard'
})
const modalId = `modal-${Math.random().toString(36).slice(2, 9)}`
const headerId = `${modalId}-header`
const closeLabel = computed(() => formatMessage(commonMessages.closeButton))
const open = ref(false)
const transition = createModalTransition()
const visible = ref(false)
const stackDepth = ref(0)
const modalBodyRef = ref<HTMLElement | null>(null)
let previousFocusEl: Element | null = null
const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scrollContainer)
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
function getFocusableElements(): HTMLElement[] {
if (!modalBodyRef.value) return []
return Array.from(modalBodyRef.value.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
}
function show(event?: MouseEvent) {
const current = transition.begin(true)
if (!current) return
props.onShow?.()
const wasEmpty = modalStackSize() === 0
stackDepth.value = modalStackSize()
open.value = true
previousFocusEl = document.activeElement
pushModal()
if (wasEmpty) modalBehavior?.onShow?.()
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleWindowKeyDown)
window.addEventListener('mousedown', updateMousePosition)
if (event) {
updateMousePosition(event)
} else {
mouseX.value = Math.round(window.innerWidth / 2)
mouseY.value = Math.round(window.innerHeight / 2)
}
setTimeout(() => {
if (!current()) return
visible.value = true
nextTick(() => {
if (!current()) return
const focusable = getFocusableElements()
if (focusable.length > 0) {
focusable[0].focus()
} else {
modalBodyRef.value?.focus()
}
})
}, 50)
}
async function hide() {
if (props.disableClose) {
return
}
const current = transition.begin(false)
if (!current) return
props.onHide?.()
resetMousePosition()
visible.value = false
popModal()
if (modalStackSize() === 0) {
modalBehavior?.onHide?.()
document.body.style.overflow = ''
}
window.removeEventListener('keydown', handleWindowKeyDown)
window.removeEventListener('mousedown', updateMousePosition)
if (previousFocusEl instanceof HTMLElement) {
previousFocusEl.focus()
}
previousFocusEl = null
//把原有300ms等待挪出来,避免闪烁
await new Promise<void>((resolve) => {
setTimeout(resolve, 300)
})
if (!current()) return
open.value = false
await nextTick()
if (current()) props.onAfterHide?.()
}
async function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
await nextTick()
if (!scrollContainer.value) return
scrollContainer.value.scrollTo({
top: scrollContainer.value.scrollHeight,
behavior,
})
requestAnimationFrame(checkScrollState)
}
defineExpose({
show,
hide,
checkScrollState,
scrollToBottom,
})
const mouseX = ref(0)
const mouseY = ref(0)
const MODAL_STACK_BASE_Z = 100
const stackZBase = computed(() => MODAL_STACK_BASE_Z + stackDepth.value * 10)
const stackOverlayZ = computed(() => stackZBase.value + 19)
const stackTauriZ = computed(() => stackZBase.value + 20)
const stackContainerZ = computed(() => stackZBase.value + 21)
const resolvedMaxWidth = computed(() => props.maxWidth ?? '60rem')
const resolvedWidth = computed(() => props.width ?? 'fit-content')
const mouseXOffset = computed(() => `calc((-50vw + ${mouseX.value}px) / 16)`)
const mouseYOffset = computed(() => `calc((-50vh + ${mouseY.value}px) / 16)`)
function updateMousePosition(event: { clientX: number; clientY: number }) {
mouseX.value = event.clientX
mouseY.value = event.clientY
}
function resetMousePosition() {
mouseX.value = Math.round(window.innerWidth / 2)
mouseY.value = Math.round(window.innerHeight / 2)
}
onUnmounted(() => {
const wasActive = transition.isActive()
transition.dispose()
if (wasActive) {
popModal()
window.removeEventListener('keydown', handleWindowKeyDown)
window.removeEventListener('mousedown', updateMousePosition)
if (modalStackSize() === 0) {
document.body.style.overflow = ''
modalBehavior?.onHide?.()
}
}
})
function handleWindowKeyDown(event: KeyboardEvent) {
if (props.closeOnEsc && event.key === 'Escape' && props.closable) {
if (!isTopmostModal()) return
hide()
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Tab') {
const focusable = getFocusableElements()
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey) {
if (document.activeElement === first) {
event.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
}
}
defineOptions({
inheritAttrs: false,
})
</script>
<style lang="scss" scoped>
.tauri-overlay {
position: fixed;
visibility: hidden;
top: 0;
left: 0;
width: 100%;
height: 100px;
z-index: v-bind(stackTauriZ);
&.shown {
opacity: 1;
visibility: visible;
}
}
.modal-overlay {
position: fixed;
inset: -5rem;
z-index: v-bind(stackOverlayZ);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.2s ease-out;
//transform: translate(
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
// calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 2)
// )
// scaleX(0.8) scaleY(0.5);
border-radius: 180px;
//filter: blur(5px);
// Fade variants
&.standard {
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--color-brand-shadow) 10%, rgba(0, 0, 0, 0.52)) 0%,
color-mix(in srgb, var(--color-brand-shadow) 2%, rgba(0, 0, 0, 0.95)) 100%
);
}
&.warning {
background: linear-gradient(to bottom, rgba(48, 38, 29, 0.52) 0%, rgba(26, 20, 14, 0.95) 100%);
}
&.danger {
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
}
@media (prefers-reduced-motion) {
transition: none !important;
}
&.shown {
opacity: 1;
visibility: visible;
pointer-events: auto;
backdrop-filter: blur(5px);
}
&.noblur {
backdrop-filter: none;
filter: none;
}
}
.modrinth-parent__no-modal-blurs {
.modal-overlay {
backdrop-filter: none;
}
}
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: v-bind(stackContainerZ);
visibility: hidden;
pointer-events: none;
transform: translate(v-bind(mouseXOffset), v-bind(mouseYOffset));
transition: none;
&.shown {
visibility: visible;
transform: translate(0, 0);
transition: all 0.2s ease-out;
> .modal-body {
opacity: 1;
visibility: visible;
scale: 1;
}
}
> .modal-body {
position: fixed;
box-shadow: 4px 4px 26px 10px rgba(0, 0, 0, 0.08);
max-height: calc(100% - 2 * var(--gap-lg));
max-width: min(v-bind(resolvedMaxWidth), calc(100% - 2 * var(--gap-lg)));
overflow-y: hidden;
overflow-x: hidden;
width: v-bind(resolvedWidth);
pointer-events: auto;
scale: 0.97;
visibility: hidden;
opacity: 0;
transition: all 0.2s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
@media screen and (max-width: 640px) {
width: calc(100% - 2 * var(--gap-lg));
}
}
}
</style>