feat(fe): standardize loading UI using shimmer SkeletonLoader across QueryStateBoundary and AGENTS.md
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import KsButton from './components/KsButton.vue'
|
||||
import KsInlineMessage from './components/KsInlineMessage.vue'
|
||||
import SkeletonLoader from './components/SkeletonLoader.vue'
|
||||
const props = defineProps<{
|
||||
loading: boolean
|
||||
processing?: boolean
|
||||
@@ -16,12 +17,14 @@ const props = defineProps<{
|
||||
expired?: boolean
|
||||
readonly?: boolean
|
||||
correlationId?: string
|
||||
skeletonType?: 'text' | 'card' | 'avatar' | 'table' | 'list'
|
||||
skeletonRows?: number
|
||||
}>()
|
||||
const emit = defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
<template>
|
||||
<section class="ks-query-boundary" :aria-busy="props.loading || props.processing">
|
||||
<KsInlineMessage v-if="props.loading" severity="info" message="불러오는 중입니다." />
|
||||
<SkeletonLoader v-if="props.loading" :type="props.skeletonType ?? 'table'" :rows="props.skeletonRows ?? 6" />
|
||||
<KsInlineMessage v-else-if="props.unauthorized" severity="warning" title="로그인 필요" message="로그인 후 다시 시도하세요." />
|
||||
<KsInlineMessage v-else-if="props.forbidden" severity="danger" title="권한 없음" message="이 작업을 수행할 권한이 없습니다." />
|
||||
<KsInlineMessage v-else-if="props.conflict" severity="warning" title="변경 충돌" message="다른 사용자가 먼저 변경했습니다. 최신 버전을 확인하세요." />
|
||||
|
||||
@@ -40,10 +40,11 @@ defineProps<Props>()
|
||||
<!-- Table skeleton -->
|
||||
<template v-else-if="type === 'table'">
|
||||
<div class="skeleton-table">
|
||||
<div v-for="i in (rows || 5)" :key="i" class="skeleton-table-row">
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-table-header">
|
||||
<div v-for="j in 6" :key="j" class="skeleton-header-cell"></div>
|
||||
</div>
|
||||
<div v-for="i in (rows || 6)" :key="i" class="skeleton-table-row">
|
||||
<div v-for="j in 6" :key="j" class="skeleton-cell"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,83 +64,89 @@ defineProps<Props>()
|
||||
|
||||
<style scoped>
|
||||
.skeleton-loader {
|
||||
animation: pulse var(--transition-slow);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ks-space-2);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
.skeleton-line, .skeleton-cell, .skeleton-header-cell, .skeleton-avatar {
|
||||
background: linear-gradient(90deg, var(--ks-color-neutral-100) 25%, var(--ks-color-neutral-200) 37%, var(--ks-color-neutral-100) 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
border-radius: var(--ks-radius-sm);
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
background: var(--color-background-secondary);
|
||||
border-radius: var(--border-radius-base);
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-background-secondary);
|
||||
flex-shrink: 0;
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 100% 50%; }
|
||||
100% { background-position: 0 50%; }
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
padding: var(--spacing-4);
|
||||
background: var(--color-background-primary);
|
||||
border: var(--border-width-1) solid var(--color-border-secondary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--ks-space-3);
|
||||
background: #fff;
|
||||
border: 1px solid var(--ks-color-neutral-200);
|
||||
border-radius: var(--ks-radius-md);
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--ks-space-2);
|
||||
}
|
||||
|
||||
.skeleton-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--ks-color-neutral-200);
|
||||
border-radius: var(--ks-radius-md);
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.skeleton-table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 2fr 2fr 1fr 1fr 1.5fr;
|
||||
gap: 8px;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--ks-color-neutral-200);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.skeleton-header-cell {
|
||||
height: 20px;
|
||||
border-radius: var(--ks-radius-sm);
|
||||
}
|
||||
|
||||
.skeleton-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-3);
|
||||
grid-template-columns: 80px 2fr 2fr 1fr 1fr 1.5fr;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skeleton-cell {
|
||||
height: 20px;
|
||||
background: var(--color-background-secondary);
|
||||
border-radius: var(--border-radius-base);
|
||||
height: 18px;
|
||||
border-radius: var(--ks-radius-sm);
|
||||
}
|
||||
|
||||
.skeleton-list-item {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-3);
|
||||
gap: var(--ks-space-3);
|
||||
margin-bottom: var(--ks-space-2);
|
||||
}
|
||||
|
||||
.skeleton-list-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
gap: var(--ks-space-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { StandardScreenState } from '../../contracts/screenContract'
|
||||
import QueryStateBoundary from '../../QueryStateBoundary.vue'
|
||||
withDefaults(defineProps<{ state?: StandardScreenState; warning?: string; error?: Error | null; staleAt?: string; correlationId?: string }>(), { state: 'READY' })
|
||||
withDefaults(defineProps<{ state?: StandardScreenState; warning?: string; error?: Error | null; staleAt?: string; correlationId?: string; skeletonType?: 'text' | 'card' | 'avatar' | 'table' | 'list'; skeletonRows?: number }>(), { state: 'READY' })
|
||||
const emit = defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
<template>
|
||||
@@ -20,6 +20,8 @@ const emit = defineEmits<{ retry: [] }>()
|
||||
:error="state === 'ERROR' ? (error ?? new Error('화면 상태 오류')) : undefined"
|
||||
:stale-at="staleAt"
|
||||
:correlation-id="correlationId"
|
||||
:skeleton-type="skeletonType"
|
||||
:skeleton-rows="skeletonRows"
|
||||
@retry="emit('retry')"
|
||||
><slot /></QueryStateBoundary>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user