feat: backups page cleanup before worlds (#5844)

* feat: card alignment + fix modals

* feat: change admon title in restore alert modal

* fix: lint

* feat: backups queue api into api-client

* feat: impl backup queue api endpoints into frontend

* feat: ack fix

* feat: bulk actions

* feat: bulk delete impl

* fix: lint

* fix: align error states

* fix: transition group

* feat: ready for qa

* fix: lint

* feat: qa

* feat: stacked admonitions component

* fix: issues with stacking

* feat: hook up admonition stacking + fix app csp for staging kyros nodes

* fix: logs.vue

* qa: close stack on admonitions click

* fix: all problems with stacked admonitions

* qa: admonition cleanup and copy overhaul draft

* fix: qa issues padding

* fix: padding bug

* feat: qa

* fix: intercom in app csp bug

* fix: positioning intercom

* feat: loading overlay on top of console + admon consistency changes

* feat: scroll indicator fade in backup delete modal + admon timestamp fix

* feat: move action bar behind modal

* fix: lint + i18n

* fix: server ping spam on filter (cache but clear on unmount)

* fix: 1 admon fade in flicker issue

* chore: temp staging undo

* qa: changes

* fix: lint

* chore: revert staging to use staging

* fix: scoping
This commit is contained in:
Calum H.
2026-04-27 20:03:48 +01:00
committed by GitHub
parent 85ae1f2074
commit 620894aecb
79 changed files with 4640 additions and 1656 deletions

View File

@@ -1,72 +1,95 @@
<template>
<div
:class="[
'relative flex flex-col rounded-2xl border-[1px] border-solid p-4 gap-3 text-contrast',
'relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-2 rounded-2xl border border-solid p-4 text-contrast',
progress != null ? 'overflow-hidden pb-5' : '',
typeClasses[type],
]"
>
<div class="flex items-start gap-2">
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component :is="getSeverityIcon(type)" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
</slot>
<div class="col-start-2 flex min-w-0 flex-1 flex-col gap-2">
<div
:class="[
'flex flex-1 gap-2',
header || $slots.header ? 'flex-col items-start' : 'items-center',
(dismissible || $slots['top-right-actions']) && 'pr-8',
]"
v-if="header || $slots.header || normalizedTimestamp"
class="flex flex-wrap items-center gap-2 text-lg font-bold leading-6"
>
<div
class="flex gap-2 items-start"
:class="header || $slots.header ? 'w-full' : 'contents'"
<slot name="header">{{ header }}</slot>
<span
v-if="normalizedTimestamp"
v-tooltip="timestampTooltip"
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
>
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component
:is="getSeverityIcon(type)"
:class="['h-6 w-6 flex-none', iconClasses[type]]"
/>
</slot>
<div v-if="header || $slots.header" class="font-semibold text-base">
<slot name="header">{{ header }}</slot>
</div>
</div>
<div class="font-normal text-contrast/80" :class="!(header || $slots.header) && 'flex-1'">
<slot>{{ body }}</slot>
</div>
<ClockIcon class="size-4" />
{{ relativeTimeLabel }}
</span>
</div>
<div v-if="$slots['top-right-actions']" class="flex shrink-0 items-center gap-2">
<slot name="top-right-actions" />
<div class="font-normal text-contrast/85">
<slot>{{ body }}</slot>
</div>
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
<slot name="actions" />
</div>
</div>
<div
v-if="$slots['top-right-actions'] || dismissible"
class="col-start-3 row-start-1 flex shrink-0 items-center gap-2 self-start"
>
<slot name="top-right-actions" />
<ButtonStyled
v-else-if="dismissible"
v-if="dismissible"
circular
type="highlight-colored-text"
type="transparent"
:color="buttonColors[type]"
hover-color-fill="background"
>
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
<XIcon class="h-4 w-4" />
<button type="button" aria-label="Dismiss" @click="$emit('dismiss')">
<XIcon />
</button>
</ButtonStyled>
</div>
<div v-if="$slots.progress">
<slot name="progress" />
</div>
<div v-if="showActionsUnderneath || $slots.actions">
<slot name="actions" />
<div
v-if="progress != null"
class="absolute inset-x-0 bottom-0 h-1 overflow-hidden"
:class="progressTrackClasses[type]"
role="progressbar"
:aria-valuenow="waiting ? undefined : Math.round(normalizedProgress * 100)"
aria-valuemin="0"
aria-valuemax="100"
>
<div
class="h-full rounded-r-full transition-[width] duration-200 ease-in-out"
:class="[
progressFillClasses[progressColor ?? type],
{ 'admonition-progress--waiting': waiting },
]"
:style="waiting ? undefined : { width: `${normalizedProgress * 100}%` }"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { ClockIcon, XIcon } from '@modrinth/assets'
import { useNow } from '@vueuse/core'
import { computed } from 'vue'
import { useFormatDateTime, useRelativeTime } from '../../composables'
import { getSeverityIcon } from '../../utils'
import ButtonStyled from './ButtonStyled.vue'
withDefaults(
const props = withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical' | 'success'
header?: string
body?: string
showActionsUnderneath?: boolean
dismissible?: boolean
progress?: number
progressColor?: 'info' | 'warning' | 'critical' | 'success' | 'blue' | 'green' | 'red'
waiting?: boolean
/** Accepts a Date, an ISO string, or a millisecond Unix timestamp. */
timestamp?: Date | string | number
}>(),
{
type: 'info',
@@ -74,6 +97,10 @@ withDefaults(
body: '',
showActionsUnderneath: false,
dismissible: false,
progress: undefined,
progressColor: undefined,
waiting: false,
timestamp: undefined,
},
)
@@ -81,6 +108,34 @@ defineEmits<{
dismiss: []
}>()
const relativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
dateStyle: 'long',
timeStyle: 'short',
})
const now = useNow({ interval: 1000 })
const normalizedProgress = computed(() => Math.min(Math.max(props.progress ?? 0, 0), 1))
const normalizedTimestamp = computed(() => {
const t = props.timestamp
if (t == null) return null
if (t instanceof Date) return t.toISOString()
if (typeof t === 'number') return new Date(t).toISOString()
return t
})
const relativeTimeLabel = computed(() => {
void now.value
const t = normalizedTimestamp.value
return t ? relativeTime(t) : ''
})
const timestampTooltip = computed(() => {
const t = normalizedTimestamp.value
return t ? formatDateTime(t) : ''
})
const typeClasses = {
info: 'border-brand-blue bg-bg-blue',
warning: 'border-brand-orange bg-bg-orange',
@@ -95,10 +150,45 @@ const iconClasses = {
success: 'text-brand-green',
}
const buttonColors: Record<string, 'blue' | 'orange' | 'red' | 'green'> = {
const buttonColors = {
info: 'blue',
warning: 'orange',
critical: 'red',
success: 'green',
} as const
const progressTrackClasses = {
info: 'bg-brand-blue/20',
warning: 'bg-brand-orange/20',
critical: 'bg-brand-red/20',
success: 'bg-brand-green/20',
}
const progressFillClasses = {
info: 'bg-brand-blue',
warning: 'bg-brand-orange',
critical: 'bg-brand-red',
success: 'bg-brand-green',
blue: 'bg-brand-blue',
green: 'bg-brand-green',
red: 'bg-brand-red',
}
</script>
<style scoped>
.admonition-progress--waiting {
animation: admonition-progress-waiting 1s linear infinite;
position: relative;
width: 20%;
}
@keyframes admonition-progress-waiting {
0% {
left: -20%;
}
100% {
left: 100%;
}
}
</style>

View File

@@ -4,6 +4,13 @@
>
<div ref="wrapperRef" class="relative min-h-0 flex-1 overflow-hidden pb-2 pt-1">
<div ref="containerRef" class="size-full" />
<Transition name="terminal-loading-fade">
<div
v-if="loading"
class="pointer-events-none absolute inset-0 z-20 animate-bpulse bg-surface-3"
aria-hidden="true"
/>
</Transition>
<div v-if="!isAtBottom" class="absolute bottom-4 right-4 z-10">
<ButtonStyled circular type="highlight" size="large">
<button class="!shadow-2xl" aria-label="Scroll to bottom" @click="scrollToBottom">
@@ -46,6 +53,7 @@ const props = withDefaults(
disableInput?: boolean
fullscreen?: boolean
emptyStateType?: 'server' | 'instance'
loading?: boolean
}>(),
{
scrollback: Infinity,
@@ -53,6 +61,7 @@ const props = withDefaults(
disableInput: false,
fullscreen: false,
emptyStateType: undefined,
loading: false,
},
)
@@ -230,6 +239,15 @@ defineExpose({
</script>
<style>
@keyframes bpulse {
50% {
filter: brightness(75%);
}
}
.animate-bpulse {
animation: bpulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.xterm {
height: 100% !important;
}
@@ -269,4 +287,14 @@ defineExpose({
border-radius: 8px !important;
contain: layout style !important;
}
.terminal-loading-fade-enter-active,
.terminal-loading-fade-leave-active {
transition: opacity 250ms ease-in-out;
}
.terminal-loading-fade-enter-from,
.terminal-loading-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -7,8 +7,8 @@
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness]'
"
:aria-label="description || label"
:aria-checked="modelValue"
:aria-label="description || label || undefined"
:aria-checked="indeterminate ? 'mixed' : modelValue"
role="checkbox"
@click="toggle"
>
@@ -25,7 +25,7 @@
<CheckIcon v-else-if="modelValue" aria-hidden="true" stroke-width="3" />
</span>
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
<span v-if="label" aria-hidden="true">
<span v-if="label" :class="labelClass" aria-hidden="true">
{{ label }}
</span>
<slot v-else />
@@ -33,6 +33,7 @@
</template>
<script setup lang="ts">
import { CheckIcon, MinusIcon } from '@modrinth/assets'
import type { HTMLAttributes } from 'vue'
const emit = defineEmits<{
'update:modelValue': [boolean]
@@ -41,6 +42,7 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
label?: string
labelClass?: HTMLAttributes['class']
disabled?: boolean
description?: string
modelValue: boolean
@@ -49,6 +51,7 @@ const props = withDefaults(
}>(),
{
label: '',
labelClass: '',
disabled: false,
description: '',
modelValue: false,

View File

@@ -12,7 +12,7 @@ const toolbarEl = ref<HTMLElement | null>(null)
const compact = ref(false)
const { stackCount } = useModalStack()
const zIndex = computed(() => 100 + stackCount.value * 10 + 31)
const zIndex = computed(() => 100 + stackCount.value * 10 + 8)
function checkCompact() {
const el = toolbarEl.value

View File

@@ -0,0 +1,617 @@
<script lang="ts"></script>
<script setup lang="ts" generic="ItemType extends StackedAdmonitionItem">
import { ChevronDownIcon, XIcon } from '@modrinth/assets'
import { AnimatePresence, Motion } from 'motion-v'
import { computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import ButtonStyled from './ButtonStyled.vue'
export type StackedAdmonitionType = 'info' | 'warning' | 'critical' | 'success'
/** Extend this interface to attach arbitrary per-item data consumed in the #item slot. */
export interface StackedAdmonitionItem {
id: string
type: StackedAdmonitionType
dismissible?: boolean
}
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<{
items: ItemType[]
peek?: number
hoverPeek?: number
expandedGap?: number
scaleStep?: number
hoverScaleStep?: number
maxVisibleBehind?: number
dismissAllEnabled?: boolean
expanded?: boolean
}>(),
{
peek: 8,
hoverPeek: 16,
expandedGap: 12,
scaleStep: 0.04,
hoverScaleStep: 0.025,
maxVisibleBehind: 2,
dismissAllEnabled: true,
expanded: undefined,
},
)
const emit = defineEmits<{
'dismiss-all': []
'update:expanded': [value: boolean]
expand: []
collapse: []
}>()
defineSlots<{
item(props: {
item: ItemType
index: number
isFront: boolean
expanded: boolean
/** Whether the consumer should render the Admonition's own dismiss button. */
dismissible: boolean
}): unknown
'header-label'(props: { count: number; expanded: boolean }): unknown
}>()
const { formatMessage } = useVIntl()
const stackId = useId()
const attrs = useAttrs()
const internalExpanded = ref(false)
const isHovered = ref(false)
const prefersReducedMotion = ref(false)
const initialMeasurementSettled = ref(false)
const enteringItemIds = ref<Set<string>>(new Set())
const actionBarHeight = ref(0)
const heights = ref<Record<string, number>>({})
const cardEls = new Map<string, HTMLElement>()
const observers = new Map<string, ResizeObserver>()
const pendingHeights = new Map<string, number>()
let flushHandle: number | null = null
let initialMeasurementHandle: number | null = null
let enteringHandle: number | null = null
let actionBarObserver: ResizeObserver | null = null
// Slot content may run effects, so measure the one real tree instead of mounting
// hidden duplicates just to discover natural card heights.
function scheduleHeightFlush() {
if (flushHandle != null) return
flushHandle = requestAnimationFrame(() => {
flushHandle = null
if (pendingHeights.size === 0) return
const next = { ...heights.value }
let changed = false
for (const [id, h] of pendingHeights) {
if (next[id] !== h) {
next[id] = h
changed = true
}
}
pendingHeights.clear()
if (changed) {
heights.value = next
if (!initialMeasurementSettled.value && initialMeasurementHandle == null) {
initialMeasurementHandle = requestAnimationFrame(() => {
initialMeasurementHandle = null
initialMeasurementSettled.value = true
})
}
}
})
}
const isExpanded = computed(() => {
if (props.items.length <= 1) return false
return props.expanded ?? internalExpanded.value
})
const hasActionBar = computed(() => props.items.length >= 2)
function itemDismissible(item: ItemType) {
return item.dismissible ?? true
}
type StackPhase = 'collapsed' | 'expanding' | 'expanded' | 'collapsing'
const phase = ref<StackPhase>(isExpanded.value ? 'expanded' : 'collapsed')
const isSettledCollapsed = computed(() => phase.value === 'collapsed')
const containerHeightSettled = ref(true)
const singleItemEntrance = ref(false)
// Behind cards morph between a collapsed placeholder and real content. The shell
// height owns that morph so mixed-height cards do not swap DOM midway through motion.
function measuredCardHeight(index: number) {
const item = props.items[index]
return item ? (heights.value[item.id] ?? 0) : 0
}
function hasMeasuredCard(index: number) {
const item = props.items[index]
return !!item && heights.value[item.id] != null
}
const frontCardHeight = computed(() => measuredCardHeight(0))
const hasBehind = computed(() => props.items.length > 1)
function currentPeek() {
return isHovered.value ? props.hoverPeek : props.peek
}
function currentScaleStep() {
return isHovered.value ? props.hoverScaleStep : props.scaleStep
}
function targetCardHeight(index: number) {
if (index === 0) return measuredCardHeight(0)
const measured = measuredCardHeight(index) || frontCardHeight.value
return isExpanded.value ? measured : frontCardHeight.value
}
const containerHeight = computed(() => {
if (isExpanded.value) {
return props.items.reduce((acc, _, i) => {
return acc + measuredCardHeight(i) + (i > 0 ? props.expandedGap : 0)
}, 0)
}
if (!hasBehind.value) return frontCardHeight.value
const behind = Math.min(props.items.length - 1, props.maxVisibleBehind)
const pad = isHovered.value ? 6 : 0
return frontCardHeight.value + currentPeek() * behind + pad
})
const stackShellHeight = computed(() => {
return containerHeight.value + (hasActionBar.value ? actionBarHeight.value : 0)
})
const containerOverflow = computed(() => {
if (isExpanded.value) return 'visible'
if (!containerHeightSettled.value) return 'hidden'
if (!hasBehind.value && hasMeasuredCard(0)) return 'visible'
return 'hidden'
})
const springTransition = computed(() =>
prefersReducedMotion.value || !initialMeasurementSettled.value
? { duration: 0 }
: { type: 'spring' as const, stiffness: 260, damping: 32 },
)
const heightTransition = computed(() =>
singleItemEntrance.value ? { duration: 0.12, ease: 'easeOut' as const } : springTransition.value,
)
const exitTransition = computed(() =>
prefersReducedMotion.value ? { duration: 0 } : { duration: 0.18 },
)
const shellExitTransition = computed(() =>
prefersReducedMotion.value ? { duration: 0 } : { duration: 0.16 },
)
function collapsedCardPosition(index: number) {
const hidden = index > props.maxVisibleBehind
return {
y: index * currentPeek(),
scale: Math.max(0.8, 1 - index * currentScaleStep()),
opacity: hidden ? 0 : 1,
}
}
function expandedCardPosition(index: number) {
let y = 0
for (let i = 0; i < index; i++) {
y += measuredCardHeight(i) + props.expandedGap
}
return { y, scale: 1, opacity: 1 }
}
function cardPosition(index: number) {
const position = isExpanded.value ? expandedCardPosition(index) : collapsedCardPosition(index)
const item = props.items[index]
if (index === 0 && singleItemEntrance.value) {
return {
...position,
opacity: 0,
}
}
if (!item || !enteringItemIds.value.has(item.id)) return position
return {
...position,
y: position.y + 8,
opacity: 0,
scale: Math.min(1, position.scale + 0.02),
}
}
function contentOpacity(index: number) {
return isExpanded.value && hasMeasuredCard(index) ? 1 : 0
}
// Newly inserted cards need an explicit two-frame enter target because Motion's
// initial state is disabled to avoid animating from zero-height on first mount.
function markEntering(ids: string[]) {
if (!initialMeasurementSettled.value || prefersReducedMotion.value || ids.length === 0) return
const next = new Set(enteringItemIds.value)
for (const id of ids) next.add(id)
enteringItemIds.value = next
if (enteringHandle != null) cancelAnimationFrame(enteringHandle)
enteringHandle = requestAnimationFrame(() => {
enteringHandle = requestAnimationFrame(() => {
enteringHandle = null
enteringItemIds.value = new Set()
})
})
}
function onContainerAnimationComplete() {
phase.value = isExpanded.value ? 'expanded' : 'collapsed'
containerHeightSettled.value = true
if (containerHeight.value > 0) {
singleItemEntrance.value = false
}
}
const containerMotionProps = computed(() => ({
onAnimationComplete: onContainerAnimationComplete,
}))
function resolveNode(el: unknown): HTMLElement | null {
if (!el) return null
if (el instanceof HTMLElement) return el
if (typeof el === 'object' && '$el' in el) {
const node = (el as { $el: unknown }).$el
return node instanceof HTMLElement ? node : null
}
return null
}
function setCardRef(id: string, el: unknown) {
const node = resolveNode(el)
if (!node) return
pendingHeights.set(id, node.offsetHeight)
scheduleHeightFlush()
if (cardEls.get(id) === node) return
observers.get(id)?.disconnect()
cardEls.set(id, node)
const ro = new ResizeObserver(() => {
pendingHeights.set(id, node.offsetHeight)
scheduleHeightFlush()
})
ro.observe(node)
observers.set(id, ro)
}
function setActionBarRef(el: unknown) {
const node = resolveNode(el)
actionBarObserver?.disconnect()
actionBarObserver = null
if (!node) {
actionBarHeight.value = 0
return
}
actionBarHeight.value = node.offsetHeight
const ro = new ResizeObserver(() => {
actionBarHeight.value = node.offsetHeight
})
ro.observe(node)
actionBarObserver = ro
}
function setExpanded(v: boolean) {
internalExpanded.value = v
emit('update:expanded', v)
if (v) emit('expand')
else emit('collapse')
}
function openStack() {
if (props.items.length <= 1 || isExpanded.value) return
phase.value = 'expanding'
setExpanded(true)
}
function closeStack() {
if (!isExpanded.value) return
phase.value = 'collapsing'
setExpanded(false)
}
function toggleExpanded() {
if (props.items.length <= 1) return
if (isExpanded.value) closeStack()
else openStack()
}
function isInteractiveTarget(target: HTMLElement | null, currentTarget: EventTarget | null) {
if (!target) return false
const interactive = target.closest(
'button, a, input, select, textarea, summary, [role="button"], [role="link"]',
)
return !!interactive && interactive !== currentTarget
}
function onContainerClick(e: MouseEvent) {
if (isExpanded.value || props.items.length <= 1) return
const target = e.target as HTMLElement | null
if (isInteractiveTarget(target, e.currentTarget)) return
openStack()
}
function onCardClick(e: MouseEvent) {
if (!isExpanded.value) return
const target = e.target as HTMLElement | null
if (isInteractiveTarget(target, e.currentTarget)) return
e.stopPropagation()
closeStack()
}
watch(
() => props.items.length,
(n, previousLength) => {
if (previousLength === 0 && n === 1 && !prefersReducedMotion.value) {
singleItemEntrance.value = true
} else if (n !== 1) {
singleItemEntrance.value = false
}
if (n <= 1 && (props.expanded ?? internalExpanded.value)) {
phase.value = 'collapsed'
setExpanded(false)
}
},
)
watch(isExpanded, (expanded, previousExpanded) => {
if (previousExpanded === undefined) {
phase.value = expanded ? 'expanded' : 'collapsed'
return
}
if (expanded && phase.value !== 'expanding') phase.value = 'expanding'
else if (!expanded && phase.value !== 'collapsing') phase.value = 'collapsing'
})
watch(containerHeight, (height, previousHeight) => {
if (height !== previousHeight) {
const openingSingleItem =
previousHeight === 0 && height > 0 && props.items.length === 1 && !prefersReducedMotion.value
if (openingSingleItem) {
singleItemEntrance.value = true
} else if (height === 0 || props.items.length !== 1) {
singleItemEntrance.value = false
}
containerHeightSettled.value =
prefersReducedMotion.value || (!initialMeasurementSettled.value && !openingSingleItem)
}
})
watch(
() => props.items.map((i) => i.id),
(ids, previousIds = []) => {
const idSet = new Set(ids)
const previousIdSet = new Set(previousIds)
markEntering(ids.filter((id) => !previousIdSet.has(id)))
for (const [id, ro] of observers) {
if (!idSet.has(id)) {
ro.disconnect()
observers.delete(id)
cardEls.delete(id)
}
}
const next: Record<string, number> = {}
for (const id of idSet) {
if (heights.value[id] != null) next[id] = heights.value[id]
}
heights.value = next
},
)
let mql: MediaQueryList | null = null
function syncRM(e: MediaQueryListEvent | MediaQueryList) {
prefersReducedMotion.value = 'matches' in e ? e.matches : false
}
onMounted(() => {
if (typeof window === 'undefined' || !window.matchMedia) return
mql = window.matchMedia('(prefers-reduced-motion: reduce)')
prefersReducedMotion.value = mql.matches
mql.addEventListener('change', syncRM)
})
onBeforeUnmount(() => {
mql?.removeEventListener('change', syncRM)
for (const ro of observers.values()) ro.disconnect()
actionBarObserver?.disconnect()
observers.clear()
cardEls.clear()
pendingHeights.clear()
if (flushHandle != null) cancelAnimationFrame(flushHandle)
if (initialMeasurementHandle != null) cancelAnimationFrame(initialMeasurementHandle)
if (enteringHandle != null) cancelAnimationFrame(enteringHandle)
})
const placeholderClasses: Record<StackedAdmonitionType, string> = {
info: 'border-brand-blue bg-bg-blue',
warning: 'border-brand-orange bg-bg-orange',
critical: 'border-brand-red bg-bg-red',
success: 'border-brand-green bg-bg-green',
}
const messages = defineMessages({
alertCount: {
id: 'ui.stacked-admonitions.alert-count',
defaultMessage: '{count, plural, one {# alert} other {# alerts}}',
},
dismissAll: {
id: 'ui.stacked-admonitions.dismiss-all',
defaultMessage: 'Dismiss all',
},
})
</script>
<template>
<AnimatePresence :initial="false">
<Motion
v-if="items.length > 0"
v-bind="attrs"
as="div"
class="relative"
:initial="false"
:animate="{ height: stackShellHeight, opacity: 1, y: 0 }"
:exit="{
height: 0,
opacity: 0,
overflow: 'hidden',
y: -4,
transition: shellExitTransition,
}"
:transition="heightTransition"
>
<Transition
enter-active-class="overflow-hidden transition-all duration-150 ease-out"
enter-from-class="-translate-y-1 opacity-0 max-h-0"
enter-to-class="translate-y-0 opacity-100 max-h-14"
leave-active-class="overflow-hidden transition-all duration-100 ease-in"
leave-from-class="translate-y-0 opacity-100 max-h-14"
leave-to-class="-translate-y-1 opacity-0 max-h-0"
>
<div v-if="hasActionBar" :ref="(el: unknown) => setActionBarRef(el)">
<div class="flex items-center justify-between pb-2">
<ButtonStyled type="transparent">
<button
type="button"
:aria-expanded="isExpanded"
:aria-controls="stackId"
@click="toggleExpanded"
>
<Motion
as="span"
class="inline-flex"
:animate="{ rotate: isExpanded ? 0 : -90 }"
:transition="{ type: 'spring', stiffness: 350, damping: 30 }"
>
<ChevronDownIcon class="h-4 w-4" />
</Motion>
<slot name="header-label" :count="items.length" :expanded="isExpanded">
{{ formatMessage(messages.alertCount, { count: items.length }) }}
</slot>
</button>
</ButtonStyled>
<ButtonStyled v-if="dismissAllEnabled" type="transparent">
<button type="button" @click="$emit('dismiss-all')">
<XIcon class="h-4 w-4" />
{{ formatMessage(messages.dismissAll) }}
</button>
</ButtonStyled>
</div>
</div>
</Transition>
<!-- Expanded-target overflow must become visible immediately so cards added
during the tail of the expand spring do not inherit collapse clipping. -->
<Motion
:id="stackId"
as="div"
class="relative"
:initial="false"
:animate="{ height: containerHeight }"
:transition="heightTransition"
:style="{ overflow: containerOverflow }"
v-bind="containerMotionProps"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@click="onContainerClick"
>
<AnimatePresence :initial="false">
<Motion
v-for="(item, index) in items"
:key="item.id"
as="div"
class="absolute inset-x-0 top-0 rounded-2xl bg-bg will-change-transform"
:initial="false"
:animate="cardPosition(index)"
:exit="{ opacity: 0, scale: 0.9, transition: exitTransition }"
:transition="springTransition"
:style="{
zIndex: items.length - index,
transformOrigin: 'top center',
}"
:aria-hidden="isSettledCollapsed && index !== 0 ? 'true' : undefined"
@click="onCardClick"
>
<template v-if="index === 0">
<div :ref="(el: unknown) => setCardRef(item.id, el)">
<slot
name="item"
:item="item"
:index="index"
:is-front="true"
:expanded="isExpanded"
:dismissible="itemDismissible(item)"
/>
</div>
</template>
<template v-else>
<div class="relative">
<Motion
as="div"
:class="[
'absolute inset-0 rounded-2xl border border-solid',
placeholderClasses[item.type],
]"
:initial="false"
:animate="{ opacity: isExpanded ? 0 : 1 }"
:transition="springTransition"
aria-hidden="true"
/>
<Motion
as="div"
:initial="false"
:animate="{ height: targetCardHeight(index) }"
:transition="springTransition"
:style="{ overflow: isExpanded ? 'visible' : 'hidden' }"
>
<Motion
as="div"
:initial="false"
:animate="{ opacity: contentOpacity(index) }"
:transition="springTransition"
>
<div
:ref="(el: unknown) => setCardRef(item.id, el)"
:inert="!isExpanded ? true : undefined"
>
<slot
name="item"
:item="item"
:index="index"
:is-front="false"
:expanded="isExpanded"
:dismissible="itemDismissible(item)"
/>
</div>
</Motion>
</Motion>
</div>
</template>
</Motion>
</AnimatePresence>
</Motion>
</Motion>
</AnimatePresence>
</template>

View File

@@ -70,6 +70,8 @@ export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue'
export { default as SmartClickable } from './SmartClickable.vue'
export type { StackedAdmonitionItem, StackedAdmonitionType } from './StackedAdmonitions.vue'
export { default as StackedAdmonitions } from './StackedAdmonitions.vue'
export { default as StatItem } from './StatItem.vue'
export { default as StyledInput } from './StyledInput.vue'
export type { TableColumn } from './Table.vue'