feat(ui): add EmptyState and ConfirmDialog components
This commit is contained in:
parent
f19e5122eb
commit
b382ff640c
2 changed files with 198 additions and 0 deletions
97
web/src/components/ui/ConfirmDialog.vue
Normal file
97
web/src/components/ui/ConfirmDialog.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<template>
|
||||
<AlertDialogRoot :open="open" @update:open="emit('update:open', $event)">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
class="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-background p-6 shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<!-- Alert icon -->
|
||||
<div class="mb-4 flex flex-col items-center gap-3 text-center">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full border border-border">
|
||||
<CircleAlert class="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<AlertDialogTitle class="text-lg font-semibold tracking-tight">{{ title }}</AlertDialogTitle>
|
||||
<AlertDialogDescription class="text-sm text-muted-foreground">{{ description }}</AlertDialogDescription>
|
||||
</div>
|
||||
|
||||
<!-- Optional type-to-confirm input -->
|
||||
<div v-if="confirmText" class="mb-4">
|
||||
<label class="mb-1.5 block text-sm text-muted-foreground">
|
||||
Type <span class="font-mono text-foreground">{{ confirmText }}</span> to confirm
|
||||
</label>
|
||||
<Input v-model="typedText" :placeholder="confirmText" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline" class="flex-1" @click="handleCancel">{{ cancelLabel }}</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button
|
||||
:variant="variant === 'destructive' ? 'destructive' : 'default'"
|
||||
class="flex-1"
|
||||
:disabled="confirmText ? typedText !== confirmText : false"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
AlertDialogRoot,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogContent,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from 'radix-vue'
|
||||
import { CircleAlert } from 'lucide-vue-next'
|
||||
import Button from './Button.vue'
|
||||
import Input from './Input.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'destructive' | 'default'
|
||||
confirmText?: string
|
||||
}>(), {
|
||||
confirmLabel: 'Confirm',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'destructive'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
confirm: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const typedText = ref('')
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm')
|
||||
emit('update:open', false)
|
||||
typedText.value = ''
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
emit('update:open', false)
|
||||
typedText.value = ''
|
||||
}
|
||||
</script>
|
||||
101
web/src/components/ui/EmptyState.vue
Normal file
101
web/src/components/ui/EmptyState.vue
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<div
|
||||
:class="cn(
|
||||
'group flex flex-col items-center justify-center text-center',
|
||||
'rounded-xl border-2 border-dashed border-border bg-card',
|
||||
'transition-all duration-300 hover:border-foreground/30',
|
||||
sizeClasses[size]
|
||||
)"
|
||||
role="status"
|
||||
:aria-label="title"
|
||||
>
|
||||
<!-- Multi-icon row (up to 3) -->
|
||||
<div v-if="icons && icons.length" class="relative mb-6 flex items-end justify-center gap-2">
|
||||
<div
|
||||
v-for="(Icon, idx) in icons.slice(0, 3)"
|
||||
:key="idx"
|
||||
:class="cn(
|
||||
'flex items-center justify-center rounded-xl border border-border bg-background shadow-sm',
|
||||
'text-muted-foreground transition-all duration-300',
|
||||
iconSizeClasses[size],
|
||||
iconPositionClasses[idx] ?? 'z-20'
|
||||
)"
|
||||
>
|
||||
<component :is="Icon" class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div class="space-y-2">
|
||||
<h3 :class="cn('font-semibold text-foreground', titleSizeClasses[size])">{{ title }}</h3>
|
||||
<p v-if="description" :class="cn('text-muted-foreground', descSizeClasses[size])">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<Button
|
||||
v-if="actionLabel"
|
||||
variant="outline"
|
||||
:class="cn('mt-6', actionSizeClasses[size])"
|
||||
@click="emit('action')"
|
||||
>
|
||||
<component v-if="actionIcon" :is="actionIcon" class="mr-2 h-4 w-4 transition-transform duration-200 group-hover/btn:rotate-90" />
|
||||
{{ actionLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Button from './Button.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
icons?: Component[]
|
||||
actionLabel?: string
|
||||
actionIcon?: Component
|
||||
size?: 'sm' | 'default' | 'lg'
|
||||
}>(), {
|
||||
size: 'default'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ action: [] }>()
|
||||
|
||||
const sizeClasses: Record<NonNullable<typeof props.size>, string> = {
|
||||
sm: 'p-6',
|
||||
default: 'p-8',
|
||||
lg: 'p-12'
|
||||
}
|
||||
|
||||
const iconSizeClasses: Record<NonNullable<typeof props.size>, string> = {
|
||||
sm: 'h-9 w-9',
|
||||
default: 'h-11 w-11',
|
||||
lg: 'h-14 w-14'
|
||||
}
|
||||
|
||||
// 3-icon layout: left (rotated -6deg, lower z), center (z-20), right (rotated +6deg, lower z)
|
||||
const iconPositionClasses: string[] = [
|
||||
'z-10 translate-y-1 -rotate-6 group-hover:-translate-x-3 group-hover:-translate-y-1 group-hover:-rotate-12',
|
||||
'z-20 group-hover:-translate-y-3',
|
||||
'z-10 translate-y-1 rotate-6 group-hover:translate-x-3 group-hover:-translate-y-1 group-hover:rotate-12'
|
||||
]
|
||||
|
||||
const titleSizeClasses: Record<NonNullable<typeof props.size>, string> = {
|
||||
sm: 'text-sm',
|
||||
default: 'text-base',
|
||||
lg: 'text-lg'
|
||||
}
|
||||
|
||||
const descSizeClasses: Record<NonNullable<typeof props.size>, string> = {
|
||||
sm: 'text-xs',
|
||||
default: 'text-sm',
|
||||
lg: 'text-base'
|
||||
}
|
||||
|
||||
const actionSizeClasses: Record<NonNullable<typeof props.size>, string> = {
|
||||
sm: 'h-7 text-xs px-3',
|
||||
default: '',
|
||||
lg: 'h-11 text-base px-6'
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Reference in a new issue