Dashboard final version, TODO: update main sidebar menu

This commit is contained in:
Simon Pocrnjič
2025-11-23 21:33:01 +01:00
parent c3de189e9d
commit c1ac92efbf
67 changed files with 5195 additions and 844 deletions
@@ -690,6 +690,11 @@ function exportToXLSX(data) {
:from="from"
:to="to"
:total="total"
:current-page="meta.current_page"
:last-page="meta.last_page"
:per-page="meta.per_page"
:page-param="pageParamName"
:per-page-param="'per_page'"<!-- legacy component may not have custom per-page name prop -->
/>
</template>
@@ -275,10 +275,10 @@ const pagination = computed(() => {
const urlParams = new URLSearchParams(window.location.search);
const perPageParam = props.perPageParamName || "per_page";
const urlPerPage = urlParams.get(perPageParam);
const pageSize = urlPerPage
? Number(urlPerPage)
: (props.meta?.per_page ?? props.pageSize);
const pageSize = urlPerPage
? Number(urlPerPage)
: props.meta?.per_page ?? props.pageSize;
return {
pageIndex: (props.meta?.current_page ?? 1) - 1,
pageSize: pageSize,
@@ -470,7 +470,7 @@ function keyOf(row) {
:per-page="pagination.pageSize"
:page-size-options="pageSizeOptions"
@update:per-page="(value) => table.setPageSize(value)"
class="px-4 py-2 border-t"
class="p-2 border-t"
>
<template #filters="slotProps">
<slot name="toolbar-filters" v-bind="slotProps" />
@@ -492,13 +492,14 @@ function keyOf(row) {
v-for="header in headerGroup.headers"
:key="header.id"
:class="[
'py-4',
'p-3',
header.column.columnDef.meta?.class,
header.column.columnDef.meta?.align === 'right'
? 'text-right'
: header.column.columnDef.meta?.align === 'center'
? 'text-center'
: 'text-left',
'bg-muted/50',
]"
>
<FlexRender
@@ -557,6 +558,7 @@ function keyOf(row) {
: cell.column.columnDef.meta?.align === 'center'
? 'text-center'
: 'text-left',
'p-3',
]"
>
<!-- Use slot if provided -->
@@ -581,7 +583,7 @@ function keyOf(row) {
</div>
<!-- Pagination -->
<div v-if="showPagination">
<div v-if="showPagination" class="border-t border-gray-200 p-4">
<!-- Server-side pagination -->
<template v-if="isServerSide && meta?.links">
<Pagination
@@ -589,6 +591,11 @@ function keyOf(row) {
:from="meta.from"
:to="meta.to"
:total="meta.total"
:current-page="meta.current_page"
:last-page="meta.last_page"
:per-page="meta.per_page"
:page-param="pageParamName"
:per-page-param="perPageParamName"
/>
</template>
@@ -45,6 +45,7 @@ const props = defineProps({
// Define columns for DataTable
const columns = [
{ key: "key", label: "#", sortable: false, align: "center" },
{ key: "name", label: "Naziv", sortable: false },
{ key: "type", label: "Vrsta", sortable: false },
{ key: "size", label: "Velikost", align: "right", sortable: false },
@@ -292,23 +293,27 @@ function closeActions() {
<template #toolbar-actions>
<slot name="add" />
</template>
<!-- Key column -->
<template #cell-key="{ row }">
<Badge
v-if="row.is_public"
variant="secondary"
class="bg-green-100 text-green-700 hover:bg-green-200 shrink-0"
>Public</Badge
>
</template>
<!-- Name column -->
<template #cell-name="{ row }">
<div>
<div class="flex items-center gap-2">
<button
type="button"
class="text-indigo-600 hover:underline"
class="text-indigo-600 hover:underline max-w-xs truncate"
:title="row.name"
@click.stop="$emit('view', row)"
>
{{ row.name }}
{{ row.name.length > 15 ? row.name.substring(0, 15) + "..." : row.name }}
</button>
<Badge
v-if="row.is_public"
variant="secondary"
class="bg-green-100 text-green-700 hover:bg-green-200"
>Public</Badge
>
</div>
<!-- Expanded description -->
<div
+2 -2
View File
@@ -20,7 +20,7 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/Components/ui/pagination";
import { Separator } from "@/components/ui/separator";
import { Separator } from "@/Components/ui/separator";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-vue-next";
import { toInteger } from "lodash";
@@ -178,7 +178,7 @@ function handlePerPageChange(value) {
<template>
<nav
class="flex flex-wrap items-center justify-between gap-3 px-2 text-sm text-gray-700 sm:px-5"
class="flex flex-wrap items-center justify-between gap-3 px-2 text-sm text-gray-700 sm:px-1"
aria-label="Pagination"
>
<!-- Mobile: Simple prev/next -->
@@ -4,7 +4,13 @@ import { router } from "@inertiajs/vue3";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import { Button } from "@/Components/ui/button";
import { PlusIcon } from "@/Utilities/Icons";
import { faUser, faMapMarkerAlt, faPhone, faEnvelope, faUniversity } from "@fortawesome/free-solid-svg-icons";
import {
faUser,
faMapMarkerAlt,
faPhone,
faEnvelope,
faUniversity,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import PersonUpdateForm from "./PersonUpdateForm.vue";
import AddressCreateForm from "./AddressCreateForm.vue";
@@ -24,6 +30,7 @@ import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
person: Object,
@@ -102,7 +109,7 @@ const closeDrawerAddAddress = () => {
editAddress.value = false;
editAddressId.value = 0;
if (!wasEdit) {
switchToTab('addresses');
switchToTab("addresses");
}
};
@@ -122,7 +129,7 @@ const closeDrawerAddPhone = () => {
editPhone.value = false;
editPhoneId.value = 0;
if (!wasEdit) {
switchToTab('phones');
switchToTab("phones");
}
};
@@ -139,7 +146,7 @@ const closeDrawerAddEmail = () => {
editEmail.value = false;
editEmailId.value = 0;
if (!wasEdit) {
switchToTab('emails');
switchToTab("emails");
}
};
@@ -156,7 +163,7 @@ const closeDrawerAddTrr = () => {
editTrr.value = false;
editTrrId.value = 0;
if (!wasEdit) {
switchToTab('trr');
switchToTab("trr");
}
};
@@ -181,35 +188,29 @@ const closeConfirm = () => {
const onConfirmDelete = async () => {
const { type, id } = confirm.value;
if (type === "email") {
router.delete(
route("person.email.delete", { person: props.person, email_id: id }),
{
preserveScroll: true,
onSuccess: () => {
closeConfirm();
},
onError: (errors) => {
console.error("Delete failed", errors);
closeConfirm();
},
}
);
router.delete(route("person.email.delete", { person: props.person, email_id: id }), {
preserveScroll: true,
onSuccess: () => {
closeConfirm();
},
onError: (errors) => {
console.error("Delete failed", errors);
closeConfirm();
},
});
} else if (type === "trr") {
router.delete(
route("person.trr.delete", { person: props.person, trr_id: id }),
{
preserveScroll: true,
onSuccess: () => {
closeConfirm();
},
onError: (errors) => {
console.error("Delete failed", errors);
closeConfirm();
},
}
);
router.delete(route("person.trr.delete", { person: props.person, trr_id: id }), {
preserveScroll: true,
onSuccess: () => {
closeConfirm();
},
onError: (errors) => {
console.error("Delete failed", errors);
closeConfirm();
},
});
} else if (type === "address") {
router.delete(
route("person.address.delete", { person: props.person, address_id: id }),
@@ -223,21 +224,18 @@ const onConfirmDelete = async () => {
closeConfirm();
},
}
);
} else if (type === "phone") {
router.delete(
route("person.phone.delete", { person: props.person, phone_id: id }),
{
preserveScroll: true,
onSuccess: () => {
closeConfirm();
},
onError: (errors) => {
console.error("Delete failed", errors);
closeConfirm();
},
}
);
} else if (type === "phone") {
router.delete(route("person.phone.delete", { person: props.person, phone_id: id }), {
preserveScroll: true,
onSuccess: () => {
closeConfirm();
},
onError: (errors) => {
console.error("Delete failed", errors);
closeConfirm();
},
});
}
};
@@ -278,20 +276,22 @@ const addressesCount = computed(() => (props.person?.addresses || []).length);
const phonesCount = computed(() => (props.person?.phones || []).length);
const emailsCount = computed(() => (props.person?.emails || []).length);
const trrsCount = computed(() => {
const list = props.person?.trrs ||
props.person?.bank_accounts ||
props.person?.accounts ||
props.person?.bankAccounts || [];
const list =
props.person?.trrs ||
props.person?.bank_accounts ||
props.person?.accounts ||
props.person?.bankAccounts ||
[];
return list.length;
});
// Format badge count (show 999+ if >= 999)
const formatBadgeCount = (count) => {
return count >= 999 ? '999+' : String(count);
return count >= 999 ? "999+" : String(count);
};
// Tab switching
const activeTab = ref('person');
const activeTab = ref("person");
const switchToTab = (tab) => {
activeTab.value = tab;
};
@@ -300,66 +300,85 @@ const switchToTab = (tab) => {
<template>
<Tabs v-model="activeTab" class="mt-2">
<TabsList class="flex w-full bg-white gap-2 p-1">
<TabsTrigger value="person" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2">
<TabsTrigger
value="person"
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2"
>
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="faUser" class="h-4 w-4" />
<span>Oseba</span>
</div>
</TabsTrigger>
<TabsTrigger value="addresses" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<TabsTrigger
value="addresses"
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3"
>
<div class="flex items-center justify-between gap-2 w-full">
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="faMapMarkerAlt" class="h-4 w-4" />
<span>Naslovi</span>
</div>
<span
<Badge
variant="secondary"
class="h-5 min-w-5 flex items-center justify-center rounded-full px-1 font-mono tabular-nums text-sm bg-primary-50 text-primary-700"
v-if="addressesCount > 0"
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(addressesCount) }}
</span>
</Badge>
</div>
</TabsTrigger>
<TabsTrigger value="phones" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<TabsTrigger
value="phones"
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3"
>
<div class="flex items-center justify-between gap-2 w-full">
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="faPhone" class="h-4 w-4" />
<span>Telefonske</span>
</div>
<span
<Badge
variant="secondary"
class="h-5 min-w-5 flex items-center justify-center rounded-full px-1 font-mono tabular-nums text-sm bg-primary-50 text-primary-700"
v-if="phonesCount > 0"
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(phonesCount) }}
</span>
</Badge>
</div>
</TabsTrigger>
<TabsTrigger value="emails" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<TabsTrigger
value="emails"
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3"
>
<div class="flex items-center justify-between gap-2 w-full">
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="faEnvelope" class="h-4 w-4" />
<span>Email</span>
</div>
<span
<Badge
variant="secondary"
class="h-5 min-w-5 flex items-center justify-center rounded-full px-1 font-mono tabular-nums text-sm bg-primary-50 text-primary-700"
v-if="emailsCount > 0"
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(emailsCount) }}
</span>
</Badge>
</div>
</TabsTrigger>
<TabsTrigger value="trr" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<TabsTrigger
value="trr"
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3"
>
<div class="flex items-center justify-between gap-2 w-full">
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="faUniversity" class="h-4 w-4" />
<span>TRR</span>
</div>
<span
<Badge
variant="secondary"
class="h-5 min-w-5 flex items-center justify-center rounded-full px-1 font-mono tabular-nums text-sm bg-primary-50 text-primary-700"
v-if="trrsCount > 0"
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(trrsCount) }}
</span>
</Badge>
</div>
</TabsTrigger>
</TabsList>
@@ -0,0 +1,100 @@
<script setup>
import { CheckIcon, ChevronsUpDownIcon } from "lucide-vue-next";
import { computed, ref } from "vue";
import { cn } from "@/lib/utils";
import { Button } from "@/Components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/Components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
const props = defineProps({
modelValue: {
type: [String, Number],
default: "",
},
items: {
type: Array,
default: () => [],
},
placeholder: {
type: String,
default: "Select item...",
},
searchPlaceholder: {
type: String,
default: "Search...",
},
emptyText: {
type: String,
default: "No item found.",
},
disabled: {
type: Boolean,
default: false,
},
buttonClass: {
type: String,
default: "w-[200px]",
},
});
const emit = defineEmits(["update:modelValue"]);
const open = ref(false);
const selectedItem = computed(() =>
props.items.find((item) => item.value === props.modelValue)
);
function selectItem(selectedValue) {
const newValue = selectedValue === props.modelValue ? "" : selectedValue;
emit("update:modelValue", newValue);
open.value = false;
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="open"
:disabled="disabled"
:class="cn('justify-between', buttonClass)"
>
{{ selectedItem?.label || placeholder }}
<ChevronsUpDownIcon class="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0" :class="buttonClass">
<Command>
<CommandInput class="h-9" :placeholder="searchPlaceholder" />
<CommandList>
<CommandEmpty>{{ emptyText }}</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="item in items"
:key="item.value"
:value="item.value"
@select="selectItem"
>
{{ item.label }}
<CheckIcon
:class="
cn('ml-auto', modelValue === item.value ? 'opacity-100' : 'opacity-0')
"
/>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,173 @@
<script setup>
import { ref, computed, watch } from "vue";
import { Popover, PopoverTrigger, PopoverContent } from "@/Components/ui/popover";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/Components/ui/command";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { Check, ChevronsUpDown, X } from "lucide-vue-next";
const props = defineProps({
modelValue: { type: Array, default: () => [] },
items: { type: Array, default: () => [] }, // [{ value, label }]
placeholder: { type: String, default: "Izberi..." },
searchPlaceholder: { type: String, default: "Išči..." },
emptyText: { type: String, default: "Ni zadetkov." },
disabled: { type: Boolean, default: false },
max: { type: Number, default: null },
clearable: { type: Boolean, default: true },
contentClass: { type: String, default: "p-0 w-[300px]" },
showSelectedChips: { type: Boolean, default: true },
chipVariant: { type: String, default: "secondary" },
});
const emit = defineEmits(["update:modelValue"]);
const open = ref(false);
const query = ref("");
const internal = ref([...props.modelValue]);
watch(
() => props.modelValue,
(val) => {
if (!Array.isArray(val)) return;
internal.value = [...val];
}
);
const valueSet = computed(() => new Set(internal.value.map(String)));
const filteredItems = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return props.items;
return props.items.filter((i) => i.label.toLowerCase().includes(q));
});
function toggle(value) {
if (props.disabled) return;
const v = String(value);
const set = new Set(internal.value.map(String));
if (set.has(v)) {
set.delete(v);
} else {
if (props.max && set.size >= props.max) return;
set.add(v);
}
internal.value = Array.from(set);
emit("update:modelValue", internal.value);
// Clear search so full list remains visible after selection
query.value = "";
}
function removeChip(value) {
const v = String(value);
internal.value = internal.value.filter((x) => String(x) !== v);
emit("update:modelValue", internal.value);
}
function clearAll() {
if (!props.clearable || props.disabled) return;
internal.value = [];
emit("update:modelValue", internal.value);
}
const summaryText = computed(() => {
if (internal.value.length === 0) return props.placeholder;
if (!props.showSelectedChips) return `${internal.value.length} izbranih`;
const labels = internal.value.map((v) => {
const found = props.items.find((i) => String(i.value) === String(v));
return found?.label || v;
});
if (labels.length <= 3) return labels.join(', ');
const firstThree = labels.slice(0, 3).join(', ');
const remaining = labels.length - 3;
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
});
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:disabled="disabled"
class="w-full justify-between gap-2"
>
<span
class="truncate"
:class="{ 'text-muted-foreground': internal.length === 0 }"
>
{{ summaryText }}
</span>
<ChevronsUpDown class="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent :class="contentClass">
<Command>
<CommandInput v-model="query" :placeholder="searchPlaceholder" />
<CommandList>
<CommandEmpty class="px-3 py-2 text-sm text-muted-foreground">{{
emptyText
}}</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="item in filteredItems"
:key="item.value"
:value="String(item.value)"
@select="() => toggle(item.value)"
class="flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
<Check
class="h-4 w-4"
:class="valueSet.has(String(item.value)) ? 'opacity-100' : 'opacity-0'"
/>
<span class="flex-1">{{ item.label }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
<div class="border-t p-2 flex items-center justify-between gap-2">
<Button
v-if="clearable"
variant="outline"
size="sm"
:disabled="internal.length === 0 || disabled"
@click="clearAll"
>Počisti</Button
>
<Button size="sm" :disabled="disabled" @click="open = false">Zapri</Button>
</div>
<div
v-if="showSelectedChips && internal.length"
class="border-t p-2 flex flex-wrap gap-1"
>
<Badge
v-for="val in internal"
:key="val"
:variant="chipVariant"
class="flex items-center gap-1"
>
<span class="truncate max-w-[140px]">
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
</span>
<button
type="button"
class="hover:text-foreground/80"
@click.stop="removeChip(val)"
:title="'Odstrani'"
>
<X class="h-3 w-3" />
</button>
</Badge>
</div>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,63 @@
<script setup>
import { ref } from "vue";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
const props = defineProps({
open: {
type: Boolean,
default: undefined,
},
align: {
type: String,
default: "center",
validator: (value) => ["start", "center", "end"].includes(value),
},
side: {
type: String,
default: "bottom",
validator: (value) => ["top", "right", "bottom", "left"].includes(value),
},
sideOffset: {
type: Number,
default: 4,
},
contentClass: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:open"]);
const internalOpen = ref(false);
const isControlled = props.open !== undefined;
function handleOpenChange(value) {
if (isControlled) {
emit("update:open", value);
} else {
internalOpen.value = value;
}
}
</script>
<template>
<Popover :open="isControlled ? open : internalOpen" @update:open="handleOpenChange">
<PopoverTrigger as-child :disabled="disabled">
<slot name="trigger" />
</PopoverTrigger>
<PopoverContent
:align="align"
:side="side"
:side-offset="sideOffset"
:class="contentClass"
>
<slot />
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,92 @@
<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>
@@ -0,0 +1,8 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue";
</script>
<template>
<div></div>
</template>
<style></style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
import AppChartToolbar from "./AppChartToolbar.vue";
const props = defineProps<{
name: string;
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
:class="
cn(
'group relative flex flex-col overflow-hidden rounded-xl border transition-all duration-200 ease-in-out hover:z-30',
props.class
)
"
>
<AppChartToolbar
:name
class="bg-card text-card-foreground relative z-20 flex justify-end border-b px-3 py-2.5"
>
<slot />
</AppChartToolbar>
<div
class="relative z-10 [&>div]:rounded-none [&>div]:border-none [&>div]:shadow-none"
>
<slot />
</div>
</div>
</template>
<style></style>
@@ -0,0 +1,57 @@
<script lang="ts" setup>
import { Button } from "@/Components/ui/button";
import { Separator } from "@/Components/ui/separator";
import TooltipProvider from "@/Components/ui/tooltip/TooltipProvider.vue";
import { cn } from "@/lib/utils";
import {
AreaChartIcon,
BarChartBigIcon,
HexagonIcon,
LineChartIcon,
MousePointer2Icon,
PieChartIcon,
RadarIcon,
} from "lucide-vue-next";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
name: string;
//code: string;
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div :class="cn('flex items-center gap-2', props.class)">
<div
class="text-muted-foreground flex items-center gap-1.5 pl-1 text-[13px] [&>svg]:h-[0.9rem] [&>svg]:w-[0.9rem]"
>
<template v-if="name.includes('ChartLine')">
<LineChartIcon /> Line Chart
</template>
<template v-else-if="name.includes('ChartBar')">
<BarChartBigIcon /> Bar Chart
</template>
<template v-else-if="name.includes('ChartPie')">
<PieChartIcon /> Pie Chart
</template>
<template v-else-if="name.includes('ChartArea')">
<AreaChartIcon /> Area Chart
</template>
<template v-else-if="name.includes('ChartRadar')">
<HexagonIcon /> Radar Chart
</template>
<template v-else-if="name.includes('ChartRadial')">
<RadarIcon /> Radial Chart
</template>
<template v-else-if="name.includes('ChartTooltip')">
<MousePointer2Icon /> Tooltip
</template>
</div>
<div class="ml-auto flex items-center gap-2 [&>form]:flex">
<Separator orientation="vertical" class="mx-0 hidden h-4! md:flex" />
</div>
</div>
</template>
<style></style>
@@ -1,7 +1,7 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { cn } from "@/lib/utils";
import { Separator } from '@/components/ui/separator';
import { Separator } from '@/Components/ui/separator';
const props = defineProps({
orientation: { type: String, required: false, default: "vertical" },
+1 -1
View File
@@ -3,7 +3,7 @@ import { cva } from "class-variance-authority";
export { default as Button } from "./Button.vue";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
+5 -1
View File
@@ -8,8 +8,12 @@ const props = defineProps({
<template>
<div
data-slot="card"
:class="
cn('rounded-xl border bg-card text-card-foreground shadow', props.class)
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-action"
:class="
cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -7,7 +7,7 @@ const props = defineProps({
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<div data-slot="card-content" :class="cn('px-6', props.class)">
<slot />
</div>
</template>
@@ -7,7 +7,10 @@ const props = defineProps({
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
@@ -7,7 +7,10 @@ const props = defineProps({
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>
@@ -7,7 +7,15 @@ const props = defineProps({
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<div
data-slot="card-header"
:class="
cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -7,7 +7,10 @@ const props = defineProps({
</script>
<template>
<h3 :class="cn('font-semibold leading-none tracking-tight', props.class)">
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>
+1
View File
@@ -1,4 +1,5 @@
export { default as Card } from "./Card.vue";
export { default as CardAction } from "./CardAction.vue";
export { default as CardContent } from "./CardContent.vue";
export { default as CardDescription } from "./CardDescription.vue";
export { default as CardFooter } from "./CardFooter.vue";
@@ -0,0 +1,80 @@
<script setup>
import { computed, ref } from 'vue'
import { useChartContext } from './interface'
const props = defineProps({
order: { type: Array, required: false }, // explicit ordering of keys
modelValue: { type: Array, required: false }, // deprecated alias
activeKeys: { type: Array, required: false }, // v-model:activeKeys target
class: { type: [String, Array, Object], required: false },
})
const emit = defineEmits(['update:activeKeys'])
const { config } = useChartContext()
// Derive ordered keys from config
const allKeys = computed(() => {
const keys = Object.keys(config)
if (props.order && props.order.length) {
return props.order.filter(k => keys.includes(k))
}
return keys
})
// Internal active state (if parent not controlling)
const internalActive = ref(allKeys.value.reduce((acc, k) => { acc[k] = true; return acc }, {}))
const activeMap = computed(() => {
// If parent passes controlled array use that
if (props.activeKeys && props.activeKeys.length) {
return props.activeKeys.reduce((acc, k) => { acc[k] = true; return acc }, {})
}
return internalActive.value
})
const items = computed(() => allKeys.value.map(k => {
const series = config[k] || {}
const color = series.color || (series.theme && (series.theme.light || series.theme.dark)) || 'var(--foreground)'
return { key: k, label: series.label || k, color, active: !!activeMap.value[k] }
}))
function toggle(key) {
// controlled mode
if (props.activeKeys) {
const next = items.value.filter(i => i.key === key ? !i.active : i.active).map(i => i.key)
// If item was active we remove it, else add it
const wasActive = activeMap.value[key]
const result = wasActive
? props.activeKeys.filter(k => k !== key)
: [...props.activeKeys, key]
emit('update:activeKeys', result)
return
}
// uncontrolled mode
internalActive.value[key] = !internalActive.value[key]
const result = Object.entries(internalActive.value).filter(([, v]) => v).map(([k]) => k)
emit('update:activeKeys', result)
}
</script>
<template>
<div :class="['flex items-center justify-center flex-wrap gap-4 text-xs select-none', props.class]">
<button
v-for="item in items"
:key="item.key"
type="button"
:class="[
'flex items-center gap-2 transition-colors',
item.active ? 'opacity-100' : 'opacity-40',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm'
]"
@click="toggle(item.key)"
>
<span class="h-2.5 w-2.5 rounded-[3px] border border-border" :style="{ background: item.color }" />
<span>{{ item.label }}</span>
</button>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,32 @@
<script setup>
import { computed } from 'vue';
import { provideChartContext } from './interface';
const props = defineProps({
config: { type: Object, required: false, default: () => ({}) },
class: { type: [String, Array, Object], required: false },
cursor: { type: Boolean, default: true },
id: { type: String, required: false },
});
// Provide context (even if empty) so descendants can attempt to read series config.
const ctx = provideChartContext(props.config, props.id);
const chartDomId = computed(() => `chart-${ctx.id}`);
</script>
<template>
<div
data-chart-container
:data-chart="chartDomId"
:class="['relative w-full flex flex-col', props.class]"
:style="{
// Default color variables; series components can override via inline style or CSS theme logic.
'--vis-primary-color': 'hsl(var(--primary))',
'--vis-secondary-color': 'hsl(var(--secondary))',
'--vis-crosshair-line-stroke-width': props.cursor ? '1px' : '0px',
'--vis-font-family': 'var(--font-sans)',
}"
>
<slot />
</div>
</template>
@@ -0,0 +1,54 @@
<script setup>
import { omit } from "@unovis/ts";
import { VisCrosshair, VisTooltip } from "@unovis/vue";
import { createApp } from "vue";
import { ChartTooltip } from ".";
const props = defineProps({
colors: { type: Array, required: false, default: () => [] },
index: { type: String, required: true },
// items now optional when using external template factory
items: { type: Array, required: false, default: () => [] },
customTooltip: { type: null, required: false },
labelFormatter: { type: Function, required: false },
// template override (e.g., componentToString(...))
template: { type: Function, required: false },
});
// Use weakmap to store reference to each datapoint for Tooltip
const wm = new WeakMap();
function internalTemplate(d) {
// If we have cached markup and no custom formatter altering title, reuse.
if (wm.has(d) && !props.labelFormatter && !props.template) {
return wm.get(d);
}
// If external template provided, delegate directly
if (props.template) {
const html = props.template(d, d[props.index]);
wm.set(d, html);
return html;
}
const componentDiv = document.createElement("div");
const omittedData = Object.entries(omit(d, [props.index])).map(([key, value]) => {
const legendReference = props.items.find((i) => i.name === key);
return { ...legendReference, value };
});
const TooltipComponent = props.customTooltip ?? ChartTooltip;
createApp(TooltipComponent, {
title: d[props.index],
data: omittedData,
labelFormatter: props.labelFormatter,
}).mount(componentDiv);
wm.set(d, componentDiv.innerHTML);
return componentDiv.innerHTML;
}
function color(d, i) {
return props.colors[i] ?? "transparent";
}
</script>
<template>
<VisTooltip :horizontal-shift="20" :vertical-shift="20" />
<VisCrosshair :template="internalTemplate" :color="color" />
</template>
@@ -0,0 +1,66 @@
<script setup>
import { BulletLegend } from "@unovis/ts";
import { VisBulletLegend } from "@unovis/vue";
import { nextTick, onMounted, ref } from "vue";
import { buttonVariants } from '@/Components/ui/button';
const props = defineProps({
items: { type: Array, required: true, default: () => [] },
});
const emits = defineEmits(["legendItemClick", "update:items"]);
const elRef = ref();
function keepStyling() {
const selector = `.${BulletLegend.selectors.item}`;
nextTick(() => {
const elements = elRef.value?.querySelectorAll(selector);
const classes = buttonVariants({ variant: "ghost", size: "xs" }).split(" ");
elements?.forEach((el) =>
el.classList.add(...classes, "!inline-flex", "!mr-2"),
);
});
}
onMounted(() => {
keepStyling();
});
function onLegendItemClick(d, i) {
emits("legendItemClick", d, i);
const isBulletActive = !props.items[i].inactive;
const isFilterApplied = props.items.some((i) => i.inactive);
if (isFilterApplied && isBulletActive) {
// reset filter
emits(
"update:items",
props.items.map((item) => ({ ...item, inactive: false })),
);
} else {
// apply selection, set other item as inactive
emits(
"update:items",
props.items.map((item) =>
item.name === d.name
? { ...d, inactive: false }
: { ...item, inactive: true },
),
);
}
keepStyling();
}
</script>
<template>
<div
ref="elRef"
class="w-max"
:style="{
'--vis-legend-bullet-size': '16px',
}"
>
<VisBulletLegend :items="items" :on-legend-item-click="onLegendItemClick" />
</div>
</template>
@@ -0,0 +1,73 @@
<script setup>
import { omit } from "@unovis/ts";
import { VisTooltip } from "@unovis/vue";
import { createApp } from "vue";
import { ChartTooltip } from ".";
const props = defineProps({
selector: { type: String, required: true },
index: { type: String, required: true },
items: { type: Array, required: false },
valueFormatter: { type: Function, required: false },
customTooltip: { type: null, required: false },
});
// Use weakmap to store reference to each datapoint for Tooltip
const wm = new WeakMap();
function template(d, i, elements) {
const valueFormatter = props.valueFormatter ?? ((tick) => `${tick}`);
if (props.index in d) {
if (wm.has(d)) {
return wm.get(d);
} else {
const componentDiv = document.createElement("div");
const omittedData = Object.entries(omit(d, [props.index])).map(
([key, value]) => {
const legendReference = props.items?.find((i) => i.name === key);
return { ...legendReference, value: valueFormatter(value) };
},
);
const TooltipComponent = props.customTooltip ?? ChartTooltip;
createApp(TooltipComponent, {
title: d[props.index],
data: omittedData,
}).mount(componentDiv);
wm.set(d, componentDiv.innerHTML);
return componentDiv.innerHTML;
}
} else {
const data = d.data;
if (wm.has(data)) {
return wm.get(data);
} else {
const style = getComputedStyle(elements[i]);
const omittedData = [
{
name: data.name,
value: valueFormatter(data[props.index]),
color: style.fill,
},
];
const componentDiv = document.createElement("div");
const TooltipComponent = props.customTooltip ?? ChartTooltip;
createApp(TooltipComponent, {
title: d[props.index],
data: omittedData,
}).mount(componentDiv);
wm.set(d, componentDiv.innerHTML);
return componentDiv.innerHTML;
}
}
}
</script>
<template>
<VisTooltip
:horizontal-shift="20"
:vertical-shift="20"
:triggers="{
[selector]: template,
}"
/>
</template>
@@ -0,0 +1,41 @@
<script setup>
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/Components/ui/card';
defineProps({
title: { type: String, required: false },
data: { type: Array, required: true },
});
</script>
<template>
<Card class="text-sm">
<CardHeader v-if="title" class="p-3 border-b">
<CardTitle>
{{ title }}
</CardTitle>
</CardHeader>
<CardContent class="p-3 min-w-[180px] flex flex-col gap-1">
<div v-for="(item, key) in data" :key="key" class="flex justify-between">
<div class="flex items-center">
<span class="w-2.5 h-2.5 mr-2">
<svg width="100%" height="100%" viewBox="0 0 30 30">
<path
d=" M 15 15 m -14, 0 a 14,14 0 1,1 28,0 a 14,14 0 1,1 -28,0"
:stroke="item.color"
:fill="item.color"
stroke-width="1"
/>
</svg>
</span>
<span>{{ item.name }}</span>
</div>
<span class="font-semibold ml-4">{{ item.value }}</span>
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,85 @@
<script setup>
// Advanced tooltip component (original implementation) inspired by external patterns.
import { computed } from 'vue';
const props = defineProps({
hideLabel: { type: Boolean, default: false },
hideIndicator: { type: Boolean, default: false },
indicator: { type: String, default: 'dot' }, // 'dot' | 'line' | 'dashed'
nameKey: { type: String, required: false },
labelKey: { type: String, required: false },
labelFormatter: { type: Function, required: false },
payload: { type: Object, required: false, default: () => ({}) },
config: { type: Object, required: false, default: () => ({}) },
class: { type: [String, Array, Object], required: false },
color: { type: String, required: false },
x: { type: [Number, Date, String], required: false },
});
// Build array of entries referencing config for label & color
const entries = computed(() => {
return Object.entries(props.payload)
.map(([key, value]) => {
const seriesKey = props.nameKey || key;
const itemConfig = props.config[seriesKey] || props.config[key] || {};
const indicatorColor = itemConfig.color || props.color;
return { key, value, itemConfig, indicatorColor };
})
.filter(e => e.itemConfig && (e.itemConfig.label || e.value !== undefined));
});
const singleSeries = computed(() => entries.value.length === 1 && props.indicator !== 'dot');
const formattedLabel = computed(() => {
if (props.hideLabel) return null;
if (props.labelFormatter && props.x !== undefined) {
return props.labelFormatter(props.x);
}
if (props.labelKey) {
const cfg = props.config[props.labelKey];
return cfg?.label || props.payload[props.labelKey];
}
return props.x instanceof Date ? props.x.toLocaleDateString() : props.x;
});
function formatValue(v) {
if (v == null) return '';
if (typeof v === 'number') return v.toLocaleString();
return v;
}
</script>
<template>
<div :class="['border border-border/50 bg-background min-w-32 rounded-lg px-2.5 py-1.5 text-xs shadow-xl', props.class]">
<div v-if="!singleSeries && formattedLabel" class="font-medium mb-1">{{ formattedLabel }}</div>
<div class="grid gap-1.5">
<div
v-for="{ key, value, itemConfig, indicatorColor } in entries"
:key="key"
class="flex w-full flex-wrap items-stretch gap-2"
:class="indicator === 'dot' ? 'items-center' : 'items-start'"
>
<!-- Indicator -->
<template v-if="!hideIndicator">
<div
:class="[
'shrink-0 rounded-[2px] border-border',
indicator === 'dot' && 'h-2.5 w-2.5',
indicator === 'line' && 'w-1 h-4',
indicator === 'dashed' && 'w-0 h-4 border-[1.5px] border-dashed bg-transparent',
singleSeries && indicator === 'dashed' && 'my-0.5'
]"
:style="{ '--color-bg': indicatorColor, '--color-border': indicatorColor }"
/>
</template>
<div :class="['flex flex-1 justify-between leading-none', singleSeries ? 'items-end' : 'items-center']">
<div class="grid gap-1.5">
<div v-if="singleSeries && formattedLabel" class="font-medium">{{ formattedLabel }}</div>
<span class="text-muted-foreground">{{ itemConfig.label || formatValue(value) }}</span>
</div>
<span v-if="value !== undefined" class="font-mono font-medium tabular-nums">{{ formatValue(value) }}</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,38 @@
import { createApp, h } from 'vue'
// Simple cache map to avoid re-rendering identical payloads.
const _cache = new Map()
function serializeKey(obj) {
try {
return JSON.stringify(obj, Object.keys(obj).sort())
} catch (e) {
return Math.random().toString(36)
}
}
// Factory returning template function for Unovis Crosshair.
// config: chart series configuration
// Component: Vue component to render
// extraProps: static props (e.g. labelKey, labelFormatter)
export function componentToString(config, Component, extraProps = {}) {
return function (_data, x) {
const row = _data && _data.data ? _data.data : _data
// Build series-only payload (exclude non-config fields like date/dateLabel)
const seriesPayload = {}
Object.keys(config).forEach(k => {
if (row && row[k] !== undefined) seriesPayload[k] = row[k]
})
const cacheKeyBase = { ...seriesPayload, __x: x }
const key = serializeKey(cacheKeyBase)
if (_cache.has(key)) return _cache.get(key)
const el = document.createElement('div')
const app = createApp(Component, { ...extraProps, payload: seriesPayload, config, x: row?.date ?? x })
app.mount(el)
const html = el.innerHTML
app.unmount()
_cache.set(key, html)
return html
}
}
+28
View File
@@ -0,0 +1,28 @@
export { default as ChartCrosshair } from "./ChartCrosshair.vue";
export { default as ChartLegend } from "./ChartLegend.vue";
export { default as ChartSingleTooltip } from "./ChartSingleTooltip.vue";
export { default as ChartTooltip } from "./ChartTooltip.vue";
export { default as ChartContainer } from "./ChartContainer.vue";
export { default as ChartTooltipContent } from "./ChartTooltipContent.vue";
export { componentToString } from "./componentToString";
export { provideChartContext, useChartContext } from "./interface";
export { default as ChartAutoLegend } from "./ChartAutoLegend.vue";
export function defaultColors(count = 3) {
const quotient = Math.floor(count / 2);
const remainder = count % 2;
const primaryCount = quotient + remainder;
const secondaryCount = quotient;
return [
...Array.from(new Array(primaryCount).keys()).map(
(i) => `hsl(var(--vis-primary-color) / ${1 - (1 / primaryCount) * i})`,
),
...Array.from(new Array(secondaryCount).keys()).map(
(i) =>
`hsl(var(--vis-secondary-color) / ${1 - (1 / secondaryCount) * i})`,
),
];
}
export * from "./interface";
@@ -0,0 +1,49 @@
// Chart interface and context helpers
// This is a fresh, original implementation inspired conceptually by patterns
// observed in external registries. No code copied.
import { inject, provide, reactive } from 'vue';
/**
* @typedef {Object} ChartSeriesConfig
* @property {string|import('vue').Component} [label] Display label or component
* @property {import('vue').Component} [icon] Optional icon component
* @property {string} [color] Static CSS color value (e.g. 'var(--chart-1)')
* @property {Object} [theme] Optional theme map: { light: string, dark: string }
*/
/**
* @typedef {Object.<string, ChartSeriesConfig>} ChartConfig
* Keys are series identifiers. Each value declares label/icon and either a
* static color or a theme object with light/dark variants.
*/
const ChartContextSymbol = Symbol('ChartContext');
let _idCounter = 0;
/**
* Provide chart context for descendants.
* @param {ChartConfig} config Reactive or plain config object.
* @param {string} [explicitId] Optional id override.
* @returns {{ id: string, config: ChartConfig }}
*/
export function provideChartContext(config, explicitId) {
const id = explicitId || `c${Date.now().toString(36)}${(++_idCounter).toString(36)}`;
const ctx = { id, config: reactive(config) };
provide(ChartContextSymbol, ctx);
return ctx;
}
/**
* Inject previously provided chart context.
* @returns {{ id: string, config: ChartConfig }}
*/
export function useChartContext() {
const ctx = inject(ChartContextSymbol, null);
if (!ctx) {
throw new Error('useChartContext() called without a provider. Wrap in <ChartContainer>.');
}
return ctx;
}
export {}; // preserve module boundaries
+24
View File
@@ -0,0 +1,24 @@
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { itemVariants } from ".";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: "div" },
class: { type: null, required: false },
variant: { type: null, required: false },
size: { type: null, required: false },
});
</script>
<template>
<Primitive
data-slot="item"
:as="as"
:as-child="asChild"
:class="cn(itemVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-actions"
:class="cn('flex items-center gap-2', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-content"
:class="
cn(
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,22 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="item-description"
:class="
cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
props.class,
)
"
>
<slot />
</p>
</template>
@@ -0,0 +1,18 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-footer"
:class="
cn('flex basis-full items-center justify-between gap-2', props.class)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
role="list"
data-slot="item-group"
:class="cn('group/item-group flex flex-col', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,18 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-header"
:class="
cn('flex basis-full items-center justify-between gap-2', props.class)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import { cn } from "@/lib/utils";
import { itemMediaVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
variant: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-media"
:data-variant="props.variant"
:class="cn(itemMediaVariants({ variant }), props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
import { Separator } from '@/Components/ui/separator';
const props = defineProps({
orientation: { type: String, required: false },
decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
</script>
<template>
<Separator
data-slot="item-separator"
orientation="horizontal"
:class="cn('my-0', props.class)"
/>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-title"
:class="
cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
props.class,
)
"
>
<slot />
</div>
</template>
+50
View File
@@ -0,0 +1,50 @@
import { cva } from "class-variance-authority";
export { default as Item } from "./Item.vue";
export { default as ItemActions } from "./ItemActions.vue";
export { default as ItemContent } from "./ItemContent.vue";
export { default as ItemDescription } from "./ItemDescription.vue";
export { default as ItemFooter } from "./ItemFooter.vue";
export { default as ItemGroup } from "./ItemGroup.vue";
export { default as ItemHeader } from "./ItemHeader.vue";
export { default as ItemMedia } from "./ItemMedia.vue";
export { default as ItemSeparator } from "./ItemSeparator.vue";
export { default as ItemTitle } from "./ItemTitle.vue";
export const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-1",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
},
);
@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from "reka-ui";
import { cn } from "@/lib/utils";
import ScrollBar from "./ScrollBar.vue";
const props = defineProps({
type: { type: String, required: false },
dir: { type: String, required: false },
scrollHideDelay: { type: Number, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<ScrollAreaRoot
v-bind="delegatedProps"
:class="cn('relative overflow-hidden', props.class)"
>
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>
@@ -0,0 +1,33 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
orientation: { type: String, required: false, default: "vertical" },
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<ScrollAreaScrollbar
v-bind="delegatedProps"
:class="
cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-px',
props.class,
)
"
>
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbar>
</template>
@@ -0,0 +1,2 @@
export { default as ScrollArea } from "./ScrollArea.vue";
export { default as ScrollBar } from "./ScrollBar.vue";