93 lines
2.5 KiB
Vue
93 lines
2.5 KiB
Vue
<script lang="ts" setup>
|
|
import {
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
CardContent,
|
|
CardFooter,
|
|
} from "@/Components/ui/card";
|
|
import { cn } from "@/lib/utils";
|
|
import { computed, HTMLAttributes } from "vue";
|
|
|
|
interface Props {
|
|
title?: string;
|
|
description?: string;
|
|
loading?: boolean;
|
|
padding?: "default" | "none" | "tight";
|
|
hover?: boolean; // subtle hover style
|
|
clickable?: boolean; // adds cursor + focus ring
|
|
disabled?: boolean;
|
|
class?: HTMLAttributes["class"];
|
|
headerClass?: HTMLAttributes["class"];
|
|
bodyClass?: HTMLAttributes["class"];
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
// Emit click for consumers if clickable
|
|
const emit = defineEmits<{ (e: "click", ev: MouseEvent): void }>();
|
|
|
|
const wrapperClasses = computed(() => {
|
|
const base = "relative transition-colors";
|
|
const hover = props.hover ? "hover:bg-muted/50" : "";
|
|
const clickable =
|
|
props.clickable && !props.disabled
|
|
? "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
: "";
|
|
const disabled = props.disabled ? "opacity-60 pointer-events-none" : "";
|
|
return [base, hover, clickable, disabled].filter(Boolean).join(" ");
|
|
});
|
|
|
|
const paddingClasses = computed(() => {
|
|
switch (props.padding) {
|
|
case "none":
|
|
return "p-0";
|
|
case "tight":
|
|
return "p-3 sm:p-4";
|
|
default:
|
|
return "p-4 sm:p-6";
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Card
|
|
:class="cn(wrapperClasses, props.class)"
|
|
@click="props.clickable && emit('click', $event)"
|
|
>
|
|
<!-- Header Slot / Fallback -->
|
|
<CardHeader
|
|
v-if="title || description || $slots.header"
|
|
:class="cn('space-y-1', headerClass)"
|
|
>
|
|
<template v-if="$slots.header">
|
|
<slot name="header" />
|
|
</template>
|
|
<template v-else>
|
|
<CardTitle v-if="title">{{ title }}</CardTitle>
|
|
<CardDescription v-if="description">{{ description }}</CardDescription>
|
|
</template>
|
|
</CardHeader>
|
|
|
|
<!-- Loading Skeleton -->
|
|
<div v-if="loading" class="animate-pulse space-y-3 px-4 py-4">
|
|
<div class="h-4 w-1/3 rounded bg-muted" />
|
|
<div class="h-3 w-1/2 rounded bg-muted" />
|
|
<div class="h-32 rounded bg-muted" />
|
|
</div>
|
|
|
|
<!-- Content Slot -->
|
|
<CardContent v-else :class="cn(paddingClasses, bodyClass)">
|
|
<slot />
|
|
</CardContent>
|
|
|
|
<!-- Footer Slot -->
|
|
<CardFooter v-if="$slots.footer" class="border-t px-4 py-3 sm:px-6">
|
|
<slot name="footer" />
|
|
</CardFooter>
|
|
</Card>
|
|
</template>
|
|
|
|
<style scoped></style>
|