feat(ui): add EmptyState and ConfirmDialog components

This commit is contained in:
Vadym Samoilenko 2026-05-13 11:00:14 +01:00
parent f19e5122eb
commit b382ff640c
2 changed files with 198 additions and 0 deletions

View 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>

View 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>