This commit is contained in:
oneao committed 2026-10-08 22:38:34 +08:00
1 parent f572dce2f6
commit e99a9fb274
356 files changed
+28877 -1055

No files matched your search

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, useSlots, watch } from 'vue'
import { Loader } from '@lucide/vue'
import { useLocale } from '../_composables/useLocale'
import { useNamespace } from '../_composables/useNamespace'
import type { SpinProps } from './types'
/**
* 加载中 —— 两种形态:
* - 无默认插槽:独立指示器(inline-flex),可带文案;
* - 有默认插槽:包裹内容并叠加半透明遮罩,容器仅 position: relative,不改变内容布局。
*/
defineOptions({ name: 'G3Spin' })
const props = withDefaults(defineProps<SpinProps>(), {
spinning: true,
delay: 0,
description: '',
})
const slots = useSlots()
const { b, e, m, is } = useNamespace('spin')
const locale = useLocale()
/** 实际显示状态:spinning 为 true 且 delay > 0 时,delay 毫秒后才置 true */
const active = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function syncActive() {
if (timer) {
clearTimeout(timer)
timer = null
}
if (!props.spinning) {
active.value = false
return
}
if (props.delay > 0) {
timer = setTimeout(() => {
active.value = true
}, props.delay)
} else {
active.value = true
}
}
watch(() => [props.spinning, props.delay], syncActive, { immediate: true })
onBeforeUnmount(() => {
if (timer) clearTimeout(timer)
})
/** 是否有默认插槽:有则「包裹内容 + 遮罩」,否则「独立指示器」 */
const nested = computed(() => !!slots.default && slots.default().length > 0)
const hasDescription = computed(() => !!props.description || !!slots.description)
const classes = computed(() => [
b(),
{
[m('nested')]: nested.value,
[is('spinning')]: active.value,
},
])
</script>
<template>
<div :class="classes" :aria-busy="active" aria-live="polite">
<!-- 形态二:内容容器,仅 position: relative,不改变内部元素位置 / 尺寸 -->
<div v-if="nested" :class="e('container')">
<slot />
</div>
<template v-if="active">
<!-- 形态一:指示器区直接落在根节点(inline-flex) -->
<template v-if="!nested">
<slot name="indicator">
<Loader :class="e('indicator')" aria-hidden="true" />
</slot>
<div
v-if="hasDescription"
:class="e('description')"
role="status"
:aria-label="locale.common.loading"
>
<slot name="description">{{ description }}</slot>
</div>
</template>
<!-- 形态二:绝对居中于遮罩之上 -->
<div v-else :class="e('section')" role="status" :aria-label="locale.common.loading">
<slot name="indicator">
<Loader :class="e('indicator')" aria-hidden="true" />
</slot>
<div v-if="hasDescription" :class="e('description')">
<slot name="description">{{ description }}</slot>
</div>
</div>
</template>
</div>
</template>
<style lang="scss">
@use '@g3soft/tokens/scss' as *;
.#{b('spin')} {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: v('spin-description-gap', 8px);
color: v('color-primary', #18181b);
}
.#{m('spin', 'nested')} {
position: relative;
display: block;
}
.#{e('spin', 'container')} {
position: relative;
}
/* 包裹形态:内容上盖一层半透明遮罩,视觉上「进行中」 */
.#{b('spin')}.#{m('spin', 'nested')}.#{is('spinning')}::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
background: color-mix(in srgb, #{v('bg-container', #fff)} 65%, transparent);
}
.#{e('spin', 'section')} {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: v('spin-description-gap', 8px);
}
.#{e('spin', 'indicator')} {
/* 块级:svg 默认行内会让容器多出行高间隙(20px 的图标把盒子撑到 24px) */
display: block;
width: v('spin-size', 20px);
height: v('spin-size', 20px);
animation: #{b('spin')}-rotate 0.8s linear infinite;
}
@keyframes #{b('spin')}-rotate {
to {
transform: rotate(360deg);
}
}
.#{e('spin', 'description')} {
color: v('text-2', rgba(0, 0, 0, 0.65));
font-size: v('font-size-sm', 12px);
}
@media (prefers-reduced-motion: reduce) {
.#{e('spin', 'indicator')} {
animation: none;
}
}
</style>
@@ -0,0 +1,2 @@
export { default as G3Spin } from './Spin.vue'
export type { SpinProps } from './types'
@@ -0,0 +1,8 @@
export interface SpinProps {
/** 是否加载中 */
spinning?: boolean
/** 延迟显示(毫秒),防短请求闪烁 */
delay?: number
/** 加载文案 */
description?: string
}