Initial commit with project structure, including configuration files, components, pages, and styles for a Nuxt.js application.

This commit is contained in:
Afiq 2025-05-26 16:18:02 +08:00
parent d44c704659
commit 5096eb1e91
155 changed files with 30959 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

20
app.vue Normal file
View File

@ -0,0 +1,20 @@
<script setup>
useSeoMeta({
title: "CORRAD UI 2025",
description: "Modern UI components for CORRAD",
});
</script>
<template>
<div>
<NuxtLoadingIndicator />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<!-- Toast containers for all positions -->
<Toast position="top-left" />
<Toast position="top-right" />
<Toast position="bottom-left" />
<Toast position="bottom-right" />
</div>
</template>

97
assets/css/base/theme.css Normal file
View File

@ -0,0 +1,97 @@
:root {
/* Font settings */
--font-sans: "Geist";
--font-fallback: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-size: 14px;
/* Layout backgrounds */
--background: 255, 255, 255;
--content-background: 250, 250, 250;
--header-background: 255, 255, 255;
--sidebar-background: 255, 255, 255;
/* Core colors */
--foreground: 9, 9, 11;
--border: 228, 228, 231;
--ring: 9, 9, 11;
--radius: 0.25rem;
/* Component colors */
--accent: 244, 244, 245;
--accent-foreground: 9, 9, 11;
--card: 255, 255, 255;
--card-foreground: 9, 9, 11;
--muted: 244, 244, 245;
--muted-foreground: 113, 113, 122;
--popover: 255, 255, 255;
--popover-foreground: 9, 9, 11;
--primary: 9, 9, 11;
--primary-foreground: 250, 250, 250;
--secondary: 244, 244, 245;
--secondary-foreground: 9, 9, 11;
/* Status colors */
--danger: 239, 68, 68;
--danger-foreground: 255, 255, 255;
--info: 59, 130, 246;
--info-foreground: 255, 255, 255;
--success: 34, 197, 94;
--success-foreground: 255, 255, 255;
--warning: 245, 158, 11;
--warning-foreground: 255, 255, 255;
}
.theme-dark {
/* Layout backgrounds */
--background: 9, 9, 11;
--content-background: 9, 9, 11;
--header-background: 9, 9, 11;
--sidebar-background: 9, 9, 11;
/* Core colors */
--foreground: 250, 250, 250;
--border: 39, 39, 42;
--ring: 212, 212, 216;
--radius: 0.25rem;
/* Component colors */
--accent: 24, 24, 27;
--accent-foreground: 250, 250, 250;
--card: 9, 9, 11;
--card-foreground: 250, 250, 250;
--muted: 39, 39, 42;
--muted-foreground: 161, 161, 170;
--popover: 9, 9, 11;
--popover-foreground: 250, 250, 250;
--primary: 250, 250, 250;
--primary-foreground: 9, 9, 11;
--secondary: 39, 39, 42;
--secondary-foreground: 250, 250, 250;
/* Status colors */
--danger: 248, 113, 113;
--danger-foreground: 255, 255, 255;
--info: 96, 165, 250;
--info-foreground: 255, 255, 255;
--success: 74, 222, 128;
--success-foreground: 255, 255, 255;
--warning: 251, 191, 36;
--warning-foreground: 255, 255, 255;
}

13
assets/css/tailwind.css Normal file
View File

@ -0,0 +1,13 @@
@import "./base/theme.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-[rgb(var(--background))] text-[rgb(var(--foreground))];
font-family: var(--font-sans), var(--font-fallback);
font-size: var(--font-size);
}
}

View File

@ -0,0 +1,79 @@
<script setup>
import { useDebounceFn } from "@vueuse/core";
defineOptions({
name: "AccordionContent",
});
const accordionValue = inject("accordionValue");
const isOpen = inject("isOpen");
const contentRef = ref(null);
const contentHeight = ref("0px");
function updateHeight() {
if (!contentRef.value) return;
// Use requestAnimationFrame for smoother animation
requestAnimationFrame(() => {
const height = contentRef.value.scrollHeight;
contentHeight.value = isOpen(accordionValue) ? `${height}px` : "0px";
});
}
// Debounce the resize handler for better performance
const debouncedResize = useDebounceFn(updateHeight, 100);
// Use MutationObserver to watch for content changes
let observer;
onMounted(() => {
updateHeight();
window.addEventListener("resize", debouncedResize);
// Create MutationObserver to watch for content changes
observer = new MutationObserver(updateHeight);
if (contentRef.value) {
observer.observe(contentRef.value, {
childList: true,
subtree: true,
characterData: true,
});
}
});
onBeforeUnmount(() => {
window.removeEventListener("resize", debouncedResize);
if (observer) {
observer.disconnect();
}
});
// Watch for isOpen changes with optimized timing
watch(
() => isOpen(accordionValue),
() => {
nextTick(updateHeight);
},
{ immediate: true }
);
</script>
<template>
<div
class="accordion-content overflow-hidden will-change-[height]"
:style="{
height: contentHeight,
'--content-height': contentHeight,
}"
>
<div ref="contentRef" class="pb-4 pt-0">
<slot />
</div>
</div>
</template>
<style scoped>
.accordion-content {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform: translateZ(0); /* Hardware acceleration */
}
</style>

View File

@ -0,0 +1,47 @@
<script setup>
defineOptions({
name: "Accordion",
});
const props = defineProps({
type: {
type: String,
default: "single",
validator: (value) => ["single", "multiple"].includes(value),
},
defaultValue: {
type: [String, Array],
default: "",
},
});
const value = ref(props.defaultValue);
const isOpen = (itemValue) => {
if (props.type === "single") {
return value.value === itemValue;
}
return Array.isArray(value.value) && value.value.includes(itemValue);
};
const toggle = (itemValue) => {
if (props.type === "single") {
value.value = value.value === itemValue ? "" : itemValue;
} else {
value.value = Array.isArray(value.value)
? value.value.includes(itemValue)
? value.value.filter((v) => v !== itemValue)
: [...value.value, itemValue]
: [itemValue];
}
};
provide("isOpen", isOpen);
provide("toggle", toggle);
</script>
<template>
<div class="divide-y rounded-md">
<slot />
</div>
</template>

View File

@ -0,0 +1,20 @@
<script setup>
defineOptions({
name: "AccordionItem",
});
const props = defineProps({
value: {
type: String,
required: true,
},
});
provide("accordionValue", props.value);
</script>
<template>
<div class="">
<slot />
</div>
</template>

View File

@ -0,0 +1,31 @@
<script setup>
defineOptions({
name: "AccordionTrigger",
});
const accordionValue = inject("accordionValue");
const isOpen = inject("isOpen");
const toggle = inject("toggle");
</script>
<template>
<button
type="button"
@click="toggle(accordionValue)"
class="flex flex-1 w-full items-center justify-between py-4 text-sm font-medium transition-all hover:underline"
:data-state="isOpen(accordionValue) ? 'open' : 'closed'"
>
<slot />
<Icon
name="ph:caret-down"
class="h-4 w-4 shrink-0 text-[rgb(var(--foreground))] transition-transform duration-300"
:class="{ 'rotate-180': isOpen(accordionValue) }"
/>
</button>
</template>
<style scoped>
.rotate-180 {
transform: rotate(180deg);
}
</style>

159
components/admin/Header.vue Normal file
View File

@ -0,0 +1,159 @@
<script setup>
const props = defineProps({
isMinimized: {
type: Boolean,
default: false,
},
});
const layoutStore = useLayoutStore();
const isRTL = computed(() => layoutStore.isRTL);
defineEmits(["toggle"]);
const unreadCount = 2;
</script>
<template>
<header
class="sticky top-0 h-14 bg-background border-b px-3 flex items-center gap-3 z-50"
:class="[isRTL ? 'flex-row-reverse' : 'flex-row']"
>
<!-- Left side -->
<button
@click="$emit('toggle')"
:class="[
'p-1.5 hover:bg-accent rounded-lg flex items-center justify-center',
isRTL ? 'order-last' : 'order-first',
]"
>
<Icon name="mdi:menu" class="w-4 h-4" />
</button>
<!-- Right side -->
<div
class="flex items-center gap-2"
:class="[isRTL ? 'mr-auto' : 'ml-auto']"
>
<!-- Theme Switcher -->
<ThemeSwitcher />
<!-- Notification -->
<Dropdown>
<DropdownTrigger class="relative">
<button
class="p-1.5 hover:bg-accent rounded-lg flex items-center justify-center"
>
<Icon name="mdi:bell" class="w-5 h-5" />
<span
v-if="unreadCount"
:class="[
'absolute top-0.5 w-3.5 h-3.5 bg-danger rounded-full text-[10px] text-danger-foreground flex items-center justify-center',
isRTL ? 'left-0.5' : 'right-0.5',
]"
>
{{ unreadCount }}
</span>
</button>
</DropdownTrigger>
<DropdownContent class="w-80">
<div class="px-3 py-2 border-b">
<h3 class="font-semibold text-sm">Notifications</h3>
</div>
<div class="py-1.5">
<DropdownItem>
<div class="flex items-start gap-3">
<div class="p-1 bg-primary/10 rounded">
<Icon name="mdi:email" class="w-3.5 h-3.5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">New message received</p>
<p class="text-xs text-muted-foreground truncate">
You have a new message from John Doe
</p>
</div>
<p class="text-xs text-muted-foreground">2m ago</p>
</div>
</DropdownItem>
<DropdownItem>
<div class="flex items-start gap-3">
<div class="p-1 bg-success/10 rounded">
<Icon
name="mdi:check-circle"
class="w-3.5 h-3.5 text-success"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">Task completed</p>
<p class="text-xs text-muted-foreground truncate">
Project X has been successfully deployed
</p>
</div>
<p class="text-xs text-muted-foreground">1h ago</p>
</div>
</DropdownItem>
</div>
<div class="px-3 py-2 border-t text-center">
<NuxtLink
to="#"
class="text-xs text-primary hover:text-primary/90"
>
View all notifications
</NuxtLink>
</div>
</DropdownContent>
</Dropdown>
<!-- User Profile -->
<Dropdown>
<DropdownTrigger>
<button
class="flex items-center gap-2 p-1 hover:bg-accent rounded-lg"
>
<div
class="w-7 h-7 rounded-full border flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 256 256"
>
<path
fill="currentColor"
d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24M74.08 197.5a64 64 0 0 1 107.84 0a87.83 87.83 0 0 1-107.84 0M96 120a32 32 0 1 1 32 32a32 32 0 0 1-32-32m97.76 66.41a79.66 79.66 0 0 0-36.06-28.75a48 48 0 1 0-59.4 0a79.66 79.66 0 0 0-36.06 28.75a88 88 0 1 1 131.52 0"
/>
</svg>
</div>
<div class="flex flex-col items-start">
<span class="text-sm font-medium">John Doe</span>
<span class="text-xs text-muted-foreground">Administrator</span>
</div>
<Icon name="mdi:chevron-down" class="w-4 h-4" />
</button>
</DropdownTrigger>
<DropdownContent>
<DropdownItem>
<div class="flex items-center gap-2">
<Icon name="mdi:account" class="w-4 h-4" />
<span class="text-sm">Profile</span>
</div>
</DropdownItem>
<DropdownItem>
<div class="flex items-center gap-2">
<Icon name="mdi:cog" class="w-4 h-4" />
<span class="text-sm">Settings</span>
</div>
</DropdownItem>
<DropdownSeparator />
<DropdownItem>
<div class="flex items-center gap-2">
<Icon name="mdi:logout" class="w-4 h-4" />
<span class="text-sm">Log out</span>
</div>
</DropdownItem>
</DropdownContent>
</Dropdown>
</div>
</header>
</template>

View File

@ -0,0 +1,465 @@
<script setup>
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
isMinimized: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["toggle"]);
const layoutStore = useLayoutStore();
const { getAdminNavigation } = useNavigation();
const menuItems = getAdminNavigation();
// Initialize expanded items based on current route
const initializeExpandedItems = () => {
const currentPath = useRoute().path;
const checkAndExpandParents = (items, parentNames = []) => {
items.forEach(item => {
if (item.children) {
const hasActiveChild = item.children.some(child => {
if (child.path === currentPath) return true;
if (child.children) {
return checkAndExpandParents([child], [...parentNames, item.name]);
}
return false;
});
if (hasActiveChild) {
// Expand all parent items in the chain
parentNames.forEach(name => expandedItems.value.add(name));
expandedItems.value.add(item.name);
}
}
if (item.path === currentPath) {
// Expand all parent items when current item is found
parentNames.forEach(name => expandedItems.value.add(name));
return true;
}
return false;
});
return items.some(item => item.path === currentPath);
};
menuItems.forEach(section => {
checkAndExpandParents(section.items);
});
};
// Call initialization on mount
onMounted(() => {
initializeExpandedItems();
});
// Watch for route changes to update expanded state
watch(
() => useRoute().path,
() => {
initializeExpandedItems();
}
);
const handleOverlayClick = () => {
emit("toggle");
};
// Compute layout-based properties
const isHorizontal = computed(() => layoutStore.sidebarLayout === "horizontal");
const isRTL = computed(() => layoutStore.isRTL);
// Compute effective isMinimized state based on screen size and layout
const effectiveIsMinimized = computed(() => {
if (isHorizontal.value) return false;
if (process.client && window.innerWidth < 768) {
return false;
}
return props.isMinimized;
});
// Compute sidebar positioning classes
const sidebarPositionClasses = computed(() => {
if (isHorizontal.value) {
return "fixed top-14 left-0 right-0 h-auto";
}
return [
"fixed inset-y-0",
isRTL.value ? "right-0" : "left-0",
props.isOpen ? "z-50" : "-translate-x-full md:translate-x-0",
effectiveIsMinimized.value ? "w-14" : "w-56",
];
});
// Track expanded menu items
const expandedItems = ref(new Set());
const toggleExpand = (itemName) => {
if (expandedItems.value.has(itemName)) {
expandedItems.value.delete(itemName);
} else {
expandedItems.value.add(itemName);
}
};
// Dynamic classes for navigation items
const navItemClasses = computed(() => {
return "flex items-center w-full px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded-md gap-3 transition-colors duration-200";
});
const sectionTitleClasses = computed(() => {
return "px-3 text-xs font-medium text-muted-foreground tracking-wider uppercase";
});
const getNestedItemClasses = (level = 1) => {
const baseClasses = "flex items-center w-full py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors duration-200 relative";
// Use fixed padding classes instead of dynamic ones
const levelPadding = {
1: 'pl-10', // First level indent
2: 'pl-16', // Second level indent
3: 'pl-20' // Third level indent
};
return `${baseClasses} ${levelPadding[level] || 'pl-10'} pr-3`;
};
const isActive = (path) => {
if (!path) return false;
const currentPath = useRoute().path;
return currentPath === path;
};
const isParentActive = (item) => {
const currentPath = useRoute().path;
const checkChildrenActive = (items) => {
return items.some(child => {
if (child.path === currentPath) return true;
if (child.children) {
return checkChildrenActive(child.children);
}
return false;
});
};
if (item.path) {
return isActive(item.path);
}
if (item.children) {
return checkChildrenActive(item.children);
}
return false;
};
// Add this to the script section
const activePopover = ref(null);
const handlePopoverTrigger = (itemName) => {
if (activePopover.value === itemName) {
activePopover.value = null;
} else {
activePopover.value = itemName;
}
};
// Add this computed property to the script section
const dropdownPosition = computed(() => {
return {
placement: 'right-start',
middleware: [{ name: 'offset', options: { mainAxis: 12 } }]
};
});
</script>
<template>
<div>
<!-- Overlay (only for vertical layout) -->
<div
v-if="props.isOpen && !isHorizontal"
@click="handleOverlayClick"
class="fixed inset-0 backdrop-blur-sm backdrop-brightness-75 z-40 md:hidden"
></div>
<!-- Sidebar -->
<aside
:class="[
'bg-sidebar border-r flex',
isHorizontal ? 'flex-row' : 'flex-col',
...sidebarPositionClasses,
'transition-all duration-200',
]"
>
<!-- Logo (only show in vertical layout) -->
<div
v-if="!isHorizontal"
class="h-14 flex items-center px-3 border-b border-border"
>
<NuxtLink to="/admin" class="flex items-center gap-2 hover:opacity-80 transition-opacity">
<div
class="w-8 h-8 bg-primary rounded-md flex items-center justify-center"
>
<Icon name="lucide:box" class="w-5 h-5 text-primary-foreground" />
</div>
<div
class="overflow-hidden transition-all duration-200"
:style="{
width: effectiveIsMinimized ? '0' : 'auto',
opacity: effectiveIsMinimized ? '0' : '1',
transform: effectiveIsMinimized ? 'translateX(-10px)' : 'translateX(0)'
}"
>
<h2 class="text-sm font-semibold whitespace-nowrap">Corrad UI</h2>
<p class="text-xs text-muted-foreground whitespace-nowrap">
Enterprise
</p>
</div>
</NuxtLink>
</div>
<!-- Navigation -->
<div
class="flex-1 overflow-y-auto py-4 px-2"
:class="{ 'px-4': isHorizontal }"
>
<nav class="space-y-1">
<template v-for="section in menuItems" :key="section.title">
<!-- Section title (only in vertical layout when not minimized) -->
<div
v-if="!effectiveIsMinimized && !isHorizontal"
class="pt-4 pb-2"
>
<h2 :class="sectionTitleClasses">
{{ section.title }}
</h2>
</div>
<!-- Navigation items -->
<div
:class="[
'space-y-1',
isHorizontal ? 'flex items-center gap-4' : '',
]"
>
<template v-for="item in section.items" :key="item.name">
<!-- Items with children -->
<template v-if="item.children">
<!-- Minimized state -->
<template v-if="effectiveIsMinimized">
<Dropdown
v-model="activePopover"
:model-value="activePopover === item.name"
placement="right"
>
<DropdownTrigger>
<button
@click="handlePopoverTrigger(item.name)"
class="submenu-trigger relative"
:class="[
navItemClasses,
activePopover === item.name ? 'bg-accent text-foreground' : ''
]"
>
<Icon :name="item.icon" class="w-4 h-4 shrink-0" />
</button>
</DropdownTrigger>
<DropdownContent
class="min-w-[200px]"
@select="() => activePopover = null"
>
<div class="py-1.5 px-2 text-sm font-medium border-b">
{{ item.name }}
</div>
<template
v-for="child in item.children"
:key="child.name"
>
<!-- Handle nested children -->
<template v-if="child.children">
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ child.name }}
</div>
<NuxtLink
v-for="subChild in child.children"
:key="subChild.name"
:to="subChild.path"
>
<DropdownItem @click="activePopover = null">
{{ subChild.name }}
</DropdownItem>
</NuxtLink>
<DropdownSeparator v-if="child !== item.children[item.children.length - 1]" />
</template>
<!-- Regular child item -->
<NuxtLink
v-else
:to="child.path"
>
<DropdownItem @click="activePopover = null">
{{ child.name }}
</DropdownItem>
</NuxtLink>
</template>
</DropdownContent>
</Dropdown>
</template>
<!-- Non-minimized state -->
<template v-else>
<button
@click="toggleExpand(item.name)"
:class="[
navItemClasses,
isParentActive(item) ? 'bg-accent text-foreground' : '',
expandedItems.has(item.name) ? '' : '',
]"
>
<Icon :name="item.icon" class="w-4 h-4 shrink-0" />
<span
class="flex-1"
:class="{ 'text-left': !isRTL, 'text-right': isRTL }"
>
{{ item.name }}
</span>
<Icon
:name="
isRTL
? 'lucide:chevron-left'
: 'lucide:chevron-right'
"
class="w-4 h-4 shrink-0 transition-transform duration-300 ease-in-out"
:class="[
{ 'ml-auto': !isRTL, 'mr-auto': isRTL },
expandedItems.has(item.name) ? 'rotate-90' : ''
]"
/>
</button>
<!-- Expanded menu in non-minimized state -->
<div
class="overflow-hidden transition-all duration-500 ease-in-out"
:style="{
maxHeight: expandedItems.has(item.name) ? '500px' : '0'
}"
>
<div class="mt-0.5 relative">
<!-- Vertical line for first level -->
<div class="absolute left-5 top-0 bottom-0 w-px bg-border"></div>
<template
v-for="(child, childIndex) in item.children"
:key="child.name"
>
<!-- Handle nested children -->
<template v-if="child.children">
<div class="relative">
<button
@click="toggleExpand(child.name)"
:class="[
getNestedItemClasses(1),
'flex items-center justify-between group',
{
'text-foreground font-medium': isParentActive(child)
}
]"
>
<span>{{ child.name }}</span>
<Icon
:name="'lucide:chevron-right'"
class="w-4 h-4 shrink-0 opacity-50 group-hover:opacity-100 transition-transform duration-300 ease-in-out"
:class="expandedItems.has(child.name) ? 'rotate-90' : ''"
/>
</button>
<!-- Nested submenu -->
<div
class="overflow-hidden transition-all duration-500 ease-in-out"
:style="{
maxHeight: expandedItems.has(child.name) ? '500px' : '0'
}"
>
<div class="py-0.5 relative">
<!-- Vertical line for second level -->
<div class="absolute left-12 top-0 bottom-0 w-px bg-border"></div>
<NuxtLink
v-for="subChild in child.children"
:key="subChild.name"
:to="subChild.path"
:class="[
getNestedItemClasses(2),
{
'text-foreground font-medium': isActive(subChild.path)
}
]"
>
{{ subChild.name }}
</NuxtLink>
</div>
</div>
</div>
</template>
<!-- Regular child item -->
<NuxtLink
v-else
:to="child.path"
:class="[
getNestedItemClasses(1),
{
'text-foreground font-medium': isActive(child.path)
}
]"
>
{{ child.name }}
</NuxtLink>
</template>
</div>
</div>
</template>
</template>
<!-- Regular item without children -->
<NuxtLink
v-else
:to="item.path"
:class="[
navItemClasses,
isActive(item.path) ? 'bg-accent text-foreground' : '',
]"
>
<Icon :name="item.icon" class="w-4 h-4 shrink-0" />
<span v-if="!effectiveIsMinimized" class="flex-1">{{
item.name
}}</span>
</NuxtLink>
</template>
</div>
</template>
</nav>
</div>
</aside>
</div>
</template>
<style scoped>
aside {
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1),
transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Hide scrollbar but maintain functionality */
.overflow-y-auto {
scrollbar-width: none;
-ms-overflow-style: none;
}
.overflow-y-auto::-webkit-scrollbar {
display: none;
}
</style>

View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "AlertDescription",
});
</script>
<template>
<div class="text-sm [&_p]:leading-relaxed">
<slot />
</div>
</template>

View File

@ -0,0 +1,50 @@
<script setup>
defineOptions({
name: "Alert",
});
const props = defineProps({
variant: {
type: String,
default: "default",
validator: (value) =>
["default", "danger", "success", "warning", "info"].includes(value),
},
});
const icons = {
default: "ph:bell",
danger: "ph:warning-octagon",
success: "ph:check-circle",
warning: "ph:warning-diamond",
info: "ph:info",
};
const baseClasses = "relative w-full rounded-lg border p-4";
const variantClasses = {
default: "bg-background text-foreground border-[rgb(var(--border))]",
danger:
"border-[rgb(var(--danger))] text-[rgb(var(--danger))] dark:border-[rgb(var(--danger))]",
success:
"border-[rgb(var(--success))] text-[rgb(var(--success))] dark:border-[rgb(var(--success))]",
warning:
"border-[rgb(var(--warning))] text-[rgb(var(--warning))] dark:border-[rgb(var(--warning))]",
info: "border-[rgb(var(--info))] text-[rgb(var(--info))] dark:border-[rgb(var(--info))]",
};
const alertClasses = computed(() => {
return `${baseClasses} ${variantClasses[props.variant]}`;
});
</script>
<template>
<div :class="alertClasses" role="alert">
<div class="flex items-start gap-4">
<Icon v-if="icons[variant]" :name="icons[variant]" class="h-5 w-5 mt-1" />
<div class="flex-1">
<slot />
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "AlertTitle",
});
</script>
<template>
<div class="mb-1 font-medium leading-none tracking-tight">
<slot />
</div>
</template>

View File

@ -0,0 +1,42 @@
<script setup>
defineOptions({
name: "AvatarGroup",
});
const props = defineProps({
max: {
type: Number,
default: 4,
},
});
const slots = useSlots();
const avatars = computed(() => {
const defaultSlot = slots.default?.() || [];
return defaultSlot.filter((slot) => slot.type?.name === "Avatar");
});
const visibleAvatars = computed(() => {
return avatars.value.slice(0, props.max);
});
const remainingCount = computed(() => {
return Math.max(0, avatars.value.length - props.max);
});
</script>
<template>
<div class="flex -space-x-2">
<template v-for="(avatar, index) in visibleAvatars" :key="index">
<div class="ring-2 ring-background">
<component :is="avatar" />
</div>
</template>
<div
v-if="remainingCount > 0"
class="relative flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm ring-2 ring-background"
>
+{{ remainingCount }}
</div>
</div>
</template>

View File

@ -0,0 +1,99 @@
<script setup>
defineOptions({
name: "Avatar",
});
const props = defineProps({
src: {
type: String,
default: "",
},
alt: {
type: String,
required: true,
},
fallback: {
type: String,
default: "",
},
size: {
type: String,
default: "default",
validator: (value) => ["sm", "default", "lg"].includes(value),
},
});
const hasError = ref(false);
const isLoading = ref(true);
const initials = computed(() => {
if (props.fallback) {
return props.fallback.toUpperCase();
}
if (!props.alt?.trim()) {
return 'NA';
}
try {
return props.alt
.trim()
.split(/\s+/)
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
} catch (error) {
console.warn('Error generating initials:', error);
return 'NA';
}
});
const sizeClasses = {
sm: "h-8 w-8 text-xs",
default: "h-10 w-10 text-sm",
lg: "h-12 w-12 text-base",
};
const avatarClasses = computed(() => {
return `relative flex shrink-0 overflow-hidden rounded-full ${
sizeClasses[props.size]
}`;
});
// Reset error state when src changes
watch(
() => props.src,
() => {
hasError.value = false;
isLoading.value = true;
}
);
</script>
<template>
<div :class="avatarClasses">
<template v-if="src && !hasError">
<div
v-show="isLoading"
class="absolute inset-0 flex items-center justify-center bg-muted"
>
<Icon name="ph:spinner" class="h-4 w-4 animate-spin" />
</div>
<img
:src="src"
:alt="alt"
@error="hasError = true"
@load="isLoading = false"
class="aspect-square h-full w-full transition-opacity duration-300"
:class="{ 'opacity-0': isLoading, 'opacity-100': !isLoading }"
/>
</template>
<span
v-else
class="flex h-full w-full items-center justify-center rounded-full bg-muted text-muted-foreground"
>
{{ initials }}
</span>
</div>
</template>

View File

@ -0,0 +1,48 @@
<script setup>
defineOptions({
name: "Badge",
});
const props = defineProps({
variant: {
type: String,
default: "default",
validator: (value) =>
[
"default",
"secondary",
"info",
"success",
"warning",
"danger",
"outline",
"ghost",
].includes(value),
},
});
const baseClasses =
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2";
const variantClasses = {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
info: "bg-info text-info-foreground hover:bg-info/80",
success: "bg-success text-success-foreground hover:bg-success/80",
warning: "bg-warning text-warning-foreground hover:bg-warning/80",
danger: "bg-danger text-danger-foreground hover:bg-danger/80",
outline:
"border border-border text-foreground hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-transparent hover:text-foreground",
};
const badgeClasses = computed(() => {
return `${baseClasses} ${variantClasses[props.variant]}`;
});
</script>
<template>
<div :class="badgeClasses">
<slot />
</div>
</template>

View File

@ -0,0 +1,44 @@
<script setup>
defineOptions({
name: "Breadcrumb",
});
const props = defineProps({
separator: {
type: String,
default: "/",
},
separatorIcon: {
type: String,
default: "",
},
});
const slots = useSlots();
const items = computed(() => {
const defaultSlot = slots.default?.() || [];
return defaultSlot.filter((slot) => slot.type?.name === "BreadcrumbItem");
});
</script>
<template>
<nav aria-label="Breadcrumb">
<ol class="flex items-center">
<template v-for="(item, index) in items" :key="index">
<component :is="item" />
<li
v-if="index < items.length - 1"
class="mx-2 select-none text-sm text-muted-foreground"
>
<Icon
v-if="separatorIcon"
:name="separatorIcon"
class="h-3 w-3"
aria-hidden="true"
/>
<span v-else aria-hidden="true">{{ separator }}</span>
</li>
</template>
</ol>
</nav>
</template>

View File

@ -0,0 +1,31 @@
<script setup>
defineOptions({
name: "BreadcrumbItem",
});
const props = defineProps({
href: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
});
</script>
<template>
<li class="inline-flex items-center">
<NuxtLink
v-if="href && !disabled"
:to="href"
class="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<slot />
</NuxtLink>
<span v-else class="text-sm text-foreground">
<slot />
</span>
</li>
</template>

View File

@ -0,0 +1,72 @@
<script setup>
const props = defineProps({
variant: {
type: String,
default: "primary",
validator: (value) =>
[
"primary",
"secondary",
"info",
"success",
"warning",
"danger",
"outline",
"ghost",
].includes(value),
},
size: {
type: String,
default: "default",
validator: (value) => ["default", "sm", "lg", "icon"].includes(value),
},
disabled: {
type: Boolean,
default: false,
},
type: {
type: String,
default: "button",
validator: (value) => ["button", "submit", "reset"].includes(value),
},
});
const baseClasses = `
inline-flex items-center justify-center rounded-md text-sm font-medium
ring-offset-background transition-colors
focus-visible:outline-none focus-visible:ring-1
focus-visible:ring-ring focus-visible:ring-offset-0
disabled:pointer-events-none disabled:opacity-50
`;
const variantClasses = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
info: "bg-info text-info-foreground hover:bg-info/90",
success: "bg-success text-success-foreground hover:bg-success/90",
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
danger: "bg-danger text-danger-foreground hover:bg-danger/90",
outline:
"border border-border bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
};
const sizeClasses = {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
};
const buttonClasses = computed(() => {
return `${baseClasses} ${variantClasses[props.variant]} ${
sizeClasses[props.size]
}`;
});
</script>
<template>
<button :class="buttonClasses" :disabled="disabled" :type="type">
<slot />
</button>
</template>

View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "CardContent",
});
</script>
<template>
<div class="p-6 pt-0">
<slot />
</div>
</template>

View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "CardDescription",
});
</script>
<template>
<p class="text-sm text-muted-foreground">
<slot />
</p>
</template>

View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "CardFooter",
});
</script>
<template>
<div class="flex items-center p-6 pt-0">
<slot />
</div>
</template>

View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "CardHeader",
});
</script>
<template>
<div class="flex flex-col space-y-1.5 p-6">
<slot />
</div>
</template>

31
components/card/index.vue Normal file
View File

@ -0,0 +1,31 @@
<script setup>
defineOptions({
name: "Card",
});
const props = defineProps({
variant: {
type: String,
default: "default",
validator: (value) => ["default", "muted", "accent"].includes(value),
},
});
const baseClasses = "rounded-lg border ";
const variantClasses = {
default: "bg-card text-card-foreground",
muted: "bg-muted text-muted-foreground",
accent: "bg-accent text-accent-foreground",
};
const cardClasses = computed(() => {
return `${baseClasses} ${variantClasses[props.variant]}`;
});
</script>
<template>
<div :class="cardClasses">
<slot />
</div>
</template>

11
components/card/title.vue Normal file
View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "CardTitle",
});
</script>
<template>
<div class="text-lg font-semibold leading-none tracking-tight">
<slot />
</div>
</template>

View File

@ -0,0 +1,100 @@
<script setup>
import { Splide } from "@splidejs/vue-splide";
import "@splidejs/splide/css";
defineOptions({
name: "Carousel",
});
const props = defineProps({
autoplay: {
type: Boolean,
default: false,
},
interval: {
type: Number,
default: 5000,
},
wrap: {
type: Boolean,
default: true,
},
controls: {
type: Boolean,
default: true,
},
indicators: {
type: Boolean,
default: true,
},
});
const options = computed(() => ({
type: props.wrap ? "loop" : "slide",
autoplay: true,
interval: 3000,
arrows: props.controls,
pagination: props.indicators,
pauseOnHover: false,
pauseOnFocus: false,
resetProgress: false,
speed: 800,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
rewind: true,
rewindSpeed: 800,
lazyLoad: "nearby",
transition: "slide",
drag: true,
snap: true,
gap: "1rem",
}));
</script>
<template>
<Splide :options="options" class="relative w-full overflow-hidden rounded-lg">
<slot />
</Splide>
</template>
<style lang="scss" scoped>
:deep(.splide__arrow) {
@apply rounded-full bg-background/50 backdrop-blur-sm;
@apply hover:bg-background/70;
}
:deep(.splide__arrow svg) {
@apply fill-primary transition-transform duration-300;
@apply h-4 w-4;
}
:deep(.splide__pagination) {
@apply bottom-4 transition-opacity duration-300 z-10;
}
:deep(.splide__pagination__page) {
@apply h-3 w-3 bg-white/50 transition-all duration-300 mx-1;
@apply hover:scale-125 hover:bg-white;
}
:deep(.splide__pagination__page.is-active) {
@apply bg-white scale-125;
}
:deep(.splide__track) {
@apply overflow-hidden;
}
:deep(.splide__slide) {
@apply transition-transform duration-500 ease-in-out;
transform-origin: center center;
}
:deep(.splide__slide.is-active) {
@apply z-10;
}
:deep(.splide__slide:not(.is-active)) {
@apply z-0;
}
</style>

View File

@ -0,0 +1,13 @@
<script setup>
import { SplideSlide } from "@splidejs/vue-splide";
defineOptions({
name: "CarouselItem",
});
</script>
<template>
<SplideSlide class="relative h-full">
<slot />
</SplideSlide>
</template>

View File

@ -0,0 +1,129 @@
<script setup>
import { onMounted, ref } from "vue";
const props = defineProps({
code: {
type: String,
required: true,
},
language: {
type: String,
default: "markup",
validator: (value) =>
[
"markup",
"javascript",
"typescript",
"vue",
"html",
"jsx",
"tsx",
"css",
"scss",
].includes(value),
},
showCopy: {
type: Boolean,
default: true,
},
initiallyExpanded: {
type: Boolean,
default: false,
},
toggleLabel: {
type: String,
default: "View Code",
},
});
const copied = ref(false);
const codeBlockRef = ref(null);
const isExpanded = ref(props.initiallyExpanded);
const copyCode = async () => {
try {
await navigator.clipboard.writeText(props.code);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error("Failed to copy code:", err);
}
};
const toggleCodeBlock = () => {
isExpanded.value = !isExpanded.value;
};
onMounted(async () => {
if (import.meta.client) {
const Prism = await import("prismjs");
await import("prismjs/themes/prism-tomorrow.css");
await import("prismjs/components/prism-javascript");
await import("prismjs/components/prism-typescript");
await import("prismjs/components/prism-jsx");
await import("prismjs/components/prism-tsx");
await import("prismjs/components/prism-markup");
await import("prismjs/components/prism-css");
await import("prismjs/components/prism-scss");
if (codeBlockRef.value) {
Prism.default.highlightElement(codeBlockRef.value);
}
}
});
</script>
<template>
<div class="my-6 w-full rounded-md border">
<!-- Header -->
<div class="flex items-center border-b">
<!-- Tab-like buttons -->
<div class="flex">
<button
@click="toggleCodeBlock"
class="flex h-10 items-center gap-2 border-r px-4 text-sm font-medium transition-colors hover:bg-muted"
:class="[
isExpanded ? 'bg-muted text-foreground' : 'text-muted-foreground',
]"
>
Code
</button>
</div>
<!-- Copy button -->
<div class="ml-auto pr-2">
<button
v-if="showCopy"
@click="copyCode"
class="flex h-8 items-center gap-2 rounded-md px-3 text-sm transition-colors hover:bg-muted"
>
<span
v-if="!copied"
class="flex items-center gap-1 text-muted-foreground"
>
<Icon name="lucide:copy" class="h-4 w-4" />
Copy
</span>
<span v-else class="flex items-center gap-1 text-success">
<Icon name="lucide:check" class="h-4 w-4" />
Copied!
</span>
</button>
</div>
</div>
<!-- Code Block -->
<div
v-show="isExpanded"
class="relative max-h-[650px] overflow-auto bg-muted"
>
<pre
class="overflow-x-auto py-4 text-[13px] leading-6"
><code :class="`language-${language} px-4`" ref="codeBlockRef">{{ code }}</code></pre>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,62 @@
<script setup>
import { inject, ref, onMounted, computed } from "vue";
defineOptions({ name: "ContextMenuContent" });
const { showMenu, position, closeMenu } = inject("context-menu");
const menuRef = ref(null);
const adjustedPosition = computed(() => {
if (!menuRef.value) return { top: position.value.y, left: position.value.x };
const menu = menuRef.value;
const menuRect = menu.getBoundingClientRect();
const windowHeight = window.innerHeight;
const windowWidth = window.innerWidth;
let top = position.value.y;
let left = position.value.x;
// Adjust vertical position
if (top + menuRect.height > windowHeight) {
top = Math.max(0, windowHeight - menuRect.height);
}
// Adjust horizontal position
if (left + menuRect.width > windowWidth) {
left = Math.max(0, windowWidth - menuRect.width);
}
return { top, left };
});
</script>
<template>
<Transition name="fade">
<div
v-if="showMenu"
ref="menuRef"
class="z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
:style="{
position: 'fixed',
top: `${adjustedPosition.top}px`,
left: `${adjustedPosition.left}px`,
}"
>
<slot />
</div>
</Transition>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
</style>

View File

@ -0,0 +1,66 @@
<script setup>
import { provide, ref, watch } from "vue";
defineOptions({ name: "ContextMenu" });
const showMenu = ref(false);
const position = ref({ x: 0, y: 0 });
const originalOverflow = ref('');
const lockScroll = () => {
originalOverflow.value = document.body.style.overflow;
document.body.style.overflow = 'hidden';
};
const unlockScroll = () => {
document.body.style.overflow = originalOverflow.value;
};
// Watch for changes in showMenu to handle scroll locking
watch(showMenu, (newValue) => {
if (newValue) {
lockScroll();
} else {
unlockScroll();
}
});
const handleContextMenu = (event) => {
event.preventDefault();
showMenu.value = true;
position.value = {
x: event.clientX,
y: event.clientY,
};
};
const closeMenu = () => {
showMenu.value = false;
};
// Provide values to child components
provide("context-menu", {
showMenu,
position,
closeMenu,
});
// Close menu when clicking outside
onMounted(() => {
document.addEventListener("click", closeMenu);
});
onBeforeUnmount(() => {
document.removeEventListener("click", closeMenu);
// Ensure we unlock scroll when component is destroyed
if (showMenu.value) {
unlockScroll();
}
});
</script>
<template>
<div @contextmenu="handleContextMenu">
<slot />
</div>
</template>

View File

@ -0,0 +1,40 @@
<script setup>
import { inject } from "vue";
defineOptions({ name: "ContextMenuItem" });
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
inset: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["click"]);
const { closeMenu } = inject("context-menu");
const handleClick = () => {
if (!props.disabled) {
emit("click");
closeMenu();
}
};
</script>
<template>
<div
:class="[
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
'focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground',
inset && 'pl-8',
disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer',
]"
@click="handleClick"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,7 @@
<script setup>
defineOptions({ name: "ContextMenuSeparator" });
</script>
<template>
<div class="-mx-1 my-1 h-px bg-border" />
</template>

View File

@ -0,0 +1,20 @@
<script setup>
defineOptions({ name: "ContextMenuTrigger" });
const props = defineProps({
as: {
type: String,
default: "div",
},
disabled: {
type: Boolean,
default: false,
},
});
</script>
<template>
<component :is="as" class="inline-block">
<slot />
</component>
</template>

View File

@ -0,0 +1,204 @@
<script setup>
const props = defineProps({
data: {
type: Array,
required: true,
},
columns: {
type: Array,
required: true,
},
});
const isClient = ref(false);
const {
columns,
sortBy,
sortDesc,
searchQuery,
currentPage,
itemsPerPage,
paginatedData,
totalPages,
toggleSort,
itemsPerPageOptions,
columnFilters,
setItemsPerPage,
setColumnFilter,
filteredData,
getCellValue,
} = useDataTable(props.data, props.columns);
const showFilters = ref(false);
onMounted(() => {
isClient.value = true;
});
</script>
<template>
<div v-if="isClient" class="dataTable">
<!-- Controls -->
<div class="mb-6 flex flex-wrap gap-4">
<!-- Search -->
<div class="flex-1 min-w-[200px]">
<FormKit v-model="searchQuery" type="text" placeholder="Search..." />
</div>
<!-- Right Controls -->
<div class="flex items-center gap-4 ml-auto">
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">Show:</span>
<FormKit
v-model.number="itemsPerPage"
type="select"
:options="itemsPerPageOptions"
@change="setItemsPerPage($event.target.value)"
:classes="{ input: 'w-20' }"
/>
</div>
<Button variant="outline" size="sm" @click="showFilters = !showFilters">
<Icon name="ph:funnel" class="h-4 w-4" />
<span class="ml-2">{{
showFilters ? "Hide Filters" : "Filters"
}}</span>
</Button>
</div>
</div>
<!-- Column Filters -->
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div v-if="showFilters" class="mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="column in columns.filter((col) => col.filterable)"
:key="column.key"
class="flex flex-col gap-1.5"
>
<FormKit
type="text"
:label="'Filter ' + column.label"
v-model="columnFilters[column.key]"
@input="setColumnFilter(column.key, columnFilters[column.key])"
/>
</div>
</div>
</div>
</Transition>
<!-- Table with Horizontal Scroll -->
<div class="border rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-muted/50">
<th
v-for="column in columns"
:key="column.key"
class="px-4 py-3 text-left text-sm font-medium whitespace-nowrap"
:class="{ 'cursor-pointer hover:bg-muted/70': column.sortable }"
@click="column.sortable && toggleSort(column)"
>
<div class="flex items-center gap-1">
{{ column.label }}
<template v-if="column.sortable">
<Icon
v-if="sortBy !== column.key"
name="ph:arrows-down-up"
class="h-4 w-4 text-gray-400"
/>
<Icon
v-else
:name="
sortDesc ? 'ph:sort-descending' : 'ph:sort-ascending'
"
class="h-4 w-4 text-primary"
/>
</template>
</div>
</th>
</tr>
</thead>
<tbody class="divide-y">
<tr
v-for="row in paginatedData"
:key="row.id"
class="hover:bg-muted/30"
>
<td
v-for="column in columns"
:key="column.key"
class="px-4 py-3 text-sm whitespace-nowrap"
>
<slot
:name="column.key"
:row="row"
:value="getCellValue(row, column)"
>
{{ getCellValue(row, column) }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div class="mt-4 flex flex-wrap items-center justify-between gap-4 text-sm">
<div class="text-muted-foreground">
Showing {{ (currentPage - 1) * itemsPerPage + 1 }} to
{{ Math.min(currentPage * itemsPerPage, filteredData.length) }} of
{{ filteredData.length }} entries
</div>
<div class="flex gap-2 ml-auto">
<Button
variant="outline"
size="sm"
:disabled="currentPage === 1"
@click="currentPage--"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
Next
</Button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.dataTable {
@apply w-full;
table {
@apply border-collapse min-w-full table-auto;
}
th {
@apply font-medium text-muted-foreground sticky top-0 bg-muted/50;
}
td {
@apply align-middle;
}
}
.formkit-outer {
@apply mb-0;
}
</style>

View File

@ -0,0 +1,70 @@
<script setup>
import { inject, ref, computed } from "vue";
defineOptions({ name: "DropdownContent" });
const { isOpen, position, triggerRef } = inject("dropdown");
const contentRef = ref(null);
const adjustedPosition = computed(() => {
if (!contentRef.value || !triggerRef.value) return position.value;
const content = contentRef.value;
const trigger = triggerRef.value;
const contentRect = content.getBoundingClientRect();
const triggerRect = trigger.getBoundingClientRect();
const windowHeight = window.innerHeight;
const windowWidth = window.innerWidth;
let { x, y } = position.value;
// Check if dropdown should appear above instead of below
const shouldShowAbove = y + contentRect.height > windowHeight;
if (shouldShowAbove) {
y = triggerRect.top - contentRect.height - 4; // 4px gap
}
// Ensure horizontal alignment stays within bounds
if (x + contentRect.width > windowWidth) {
// Align to right edge of trigger
x = triggerRect.right - contentRect.width;
}
// Prevent going off-screen left
x = Math.max(8, x); // 8px minimum margin
// Prevent going off-screen top
y = Math.max(8, y); // 8px minimum margin
return { x, y };
});
</script>
<template>
<Transition name="dropdown">
<div
v-if="isOpen"
ref="contentRef"
class="fixed z-[100] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
:style="{
top: `${adjustedPosition.y}px`,
left: `${adjustedPosition.x}px`,
}"
>
<slot />
</div>
</Transition>
</template>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: scale(0.95);
}
</style>

View File

@ -0,0 +1,88 @@
<script setup>
import { provide, ref, watch, onMounted, onBeforeUnmount } from 'vue';
defineOptions({ name: 'Dropdown' });
const props = defineProps({
placement: {
type: String,
default: 'bottom'
}
});
const isOpen = ref(false);
const position = ref({ x: 0, y: 0 });
const triggerRef = ref(null);
const originalOverflow = ref('');
const lockScroll = () => {
originalOverflow.value = document.body.style.overflow;
document.body.style.overflow = 'hidden';
};
const unlockScroll = () => {
document.body.style.overflow = originalOverflow.value;
};
// Watch for changes in isOpen to handle scroll locking
watch(isOpen, (newValue) => {
if (newValue) {
lockScroll();
} else {
unlockScroll();
}
});
const toggle = () => {
isOpen.value = !isOpen.value;
if (isOpen.value && triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect();
if (props.placement === 'right') {
position.value = {
x: rect.right + 8, // Add small gap
y: rect.top,
};
} else {
position.value = {
x: rect.left,
y: rect.bottom + 4, // Add small gap
};
}
}
};
const close = () => {
isOpen.value = false;
};
provide('dropdown', {
isOpen,
position,
triggerRef,
toggle,
close,
});
onMounted(() => {
document.addEventListener('click', (event) => {
const target = event.target;
if (!triggerRef.value?.contains(target)) {
close();
}
});
});
onBeforeUnmount(() => {
// Ensure we unlock scroll when component is destroyed
if (isOpen.value) {
unlockScroll();
}
});
</script>
<template>
<div class="relative inline-block text-left z-[100]">
<slot />
</div>
</template>

View File

@ -0,0 +1,36 @@
<script setup>
defineOptions({ name: 'DropdownItem' });
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
inset: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
const handleClick = () => {
if (!props.disabled) {
emit('click');
}
};
</script>
<template>
<div
:class="[
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
'focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground z-[100]',
inset && 'pl-8',
disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer',
]"
@click="handleClick"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,7 @@
<script setup>
defineOptions({ name: 'DropdownSeparator' });
</script>
<template>
<div class="-mx-1 my-1 h-px bg-border" />
</template>

View File

@ -0,0 +1,13 @@
<script setup>
import { inject } from 'vue';
defineOptions({ name: 'DropdownTrigger' });
const { triggerRef, toggle } = inject('dropdown');
</script>
<template>
<div ref="triggerRef" @click="toggle">
<slot />
</div>
</template>

View File

@ -0,0 +1,24 @@
import { createInput } from "@formkit/vue";
import otp from "./inputs/otp";
import dropzone from "./inputs/dropzone";
import switchInput from "./inputs/switch";
import mask from "./inputs/mask";
import combobox from "./inputs/combobox";
export default {
otp: createInput(otp, {
props: ["length"],
}),
dropzone: createInput(dropzone, {
props: ["multiple", "accept", "maxSize", "maxFiles"],
}),
switch: createInput(switchInput, {
props: ["size"],
}),
mask: createInput(mask, {
props: ["mask"],
}),
combobox: createInput(combobox, {
props: ["options"],
}),
};

View File

@ -0,0 +1,167 @@
<script setup>
import { ref, computed, onMounted, shallowRef } from "vue";
const props = defineProps({
context: {
type: Object,
required: true,
},
});
const isOpen = ref(false);
const searchQuery = ref("");
const selectedItems = ref([]);
// Use shallowRef for options since we don't need deep reactivity
const options = shallowRef([]);
// Cache options on mount
onMounted(() => {
options.value = props.context.options || [];
});
// Debounced search implementation
let searchTimeout;
const debouncedSearch = (query) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchQuery.value = query;
}, 150);
};
// Memoize filtered options with more efficient filtering
const filteredOptions = computed(() => {
const query = searchQuery.value.toLowerCase();
if (!query) {
return options.value.filter(
(option) => !selectedItems.value.includes(option)
);
}
return options.value.filter(
(option) =>
!selectedItems.value.includes(option) &&
option.label.toLowerCase().includes(query)
);
});
// Handle option selection
const selectOption = (option) => {
selectedItems.value.push(option);
props.context.node.input(selectedItems.value);
searchQuery.value = "";
isOpen.value = false;
};
// Handle option removal
const removeOption = (optionToRemove) => {
selectedItems.value = selectedItems.value.filter(
(option) => option !== optionToRemove
);
props.context.node.input(selectedItems.value);
};
// Handle input focus
const handleFocus = () => {
isOpen.value = true;
};
// Handle input blur
const handleBlur = (event) => {
// Check if the related target is within the component
if (!event.relatedTarget?.closest(".formkit-combobox")) {
isOpen.value = false;
}
};
// Handle keyboard navigation
const handleKeydown = (event) => {
if (event.key === "Escape") {
isOpen.value = false;
} else if (
event.key === "Backspace" &&
!searchQuery.value &&
selectedItems.value.length > 0
) {
removeOption(selectedItems.value[selectedItems.value.length - 1]);
}
};
// Initialize value from context
if (props.context.value) {
selectedItems.value = Array.isArray(props.context.value)
? props.context.value
: [props.context.value];
}
</script>
<template>
<div class="formkit-combobox">
<!-- Selected items and input container -->
<div
:class="[
context.classes.input,
context.disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-text hover:/80',
]"
@click="handleFocus"
>
<!-- Selected items pills -->
<span
v-for="item in selectedItems"
:key="item.value"
:class="context.classes.pill"
>
{{ item.label }}
<button
type="button"
:class="context.classes.removeButton"
@click.stop="removeOption(item)"
:disabled="context.disabled"
>
<Icon name="ph:x" :class="context.classes.removeIcon" />
</button>
</span>
<!-- Search input -->
<div :class="context.classes.inputWrapper">
<input
type="text"
:class="context.classes.searchInput"
:placeholder="selectedItems.length === 0 ? context.placeholder : ''"
:value="searchQuery"
@input="(e) => debouncedSearch(e.target.value)"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
:disabled="context.disabled"
/>
<Icon
:name="isOpen ? 'ph:caret-up' : 'ph:caret-down'"
:class="[
context.classes.caret,
context.disabled ? 'opacity-30' : 'group-hover:opacity-70',
]"
/>
</div>
</div>
<!-- Dropdown -->
<div
v-if="isOpen && filteredOptions.length > 0"
:class="context.classes.dropdown"
>
<div :class="context.classes.dropdownInner">
<button
v-for="option in filteredOptions"
:key="option.value"
:class="context.classes.option"
@click="selectOption(option)"
@mousedown.prevent
>
{{ option.label }}
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,238 @@
<script setup>
import { ref, computed, watch } from "vue";
const props = defineProps({
context: {
type: Object,
required: true,
},
});
const dragCounter = ref(0);
const fileInput = ref(null);
const isDragging = computed(() => dragCounter.value > 0);
// Get props directly from context
const multiple = computed(() => props.context.multiple || false);
const accept = computed(() => props.context.accept);
const maxSize = computed(() => props.context.maxSize || 5242880); // 5MB default
const maxFiles = computed(() => props.context.maxFiles || 10);
// Initialize files array if multiple is true
const currentFiles = ref([]);
// Handle file selection
const handleFiles = (files) => {
const selectedFiles = Array.from(files);
// Validate number of files
if (!multiple.value && selectedFiles.length > 1) {
props.context.node.setErrors(["Only one file can be uploaded"]);
return;
}
// For multiple files, check total count including existing files
if (multiple.value) {
const totalFiles = [...(currentFiles.value || []), ...selectedFiles];
if (totalFiles.length > maxFiles.value) {
props.context.node.setErrors([`Maximum ${maxFiles.value} files allowed`]);
return;
}
}
// Validate file size and type
const invalidFiles = selectedFiles.filter((file) => {
if (file.size > maxSize.value) {
return true;
}
if (accept.value) {
const acceptedTypes = accept.value.split(",").map((type) => type.trim());
const fileType = file.type;
const fileExtension = `.${file.name.split(".").pop()}`;
return !acceptedTypes.some((type) => {
if (type.startsWith(".")) {
return type === fileExtension;
}
if (type.endsWith("/*")) {
return fileType.startsWith(type.slice(0, -2));
}
return type === fileType;
});
}
return false;
});
if (invalidFiles.length) {
props.context.node.setErrors([
`Some files are invalid. Please check file type and size (max ${
maxSize.value / 1024 / 1024
}MB)`,
]);
return;
}
// Update value based on multiple flag
if (multiple.value) {
// Append new files to existing ones
const updatedFiles = [...(currentFiles.value || []), ...selectedFiles];
currentFiles.value = updatedFiles;
props.context.node.input(updatedFiles);
} else {
// Single file mode
currentFiles.value = [selectedFiles[0]];
props.context.node.input(selectedFiles[0]);
}
};
// Add remove file functionality
const removeFile = (fileToRemove) => {
if (multiple.value) {
const updatedFiles = currentFiles.value.filter(
(file) => file !== fileToRemove
);
currentFiles.value = updatedFiles;
props.context.node.input(updatedFiles);
} else {
currentFiles.value = [];
props.context.node.input(null);
}
};
// Drag and drop handlers
const handleDragEnter = (e) => {
e.preventDefault();
dragCounter.value++;
};
const handleDragLeave = (e) => {
e.preventDefault();
dragCounter.value--;
};
const handleDrop = (e) => {
e.preventDefault();
dragCounter.value = 0;
handleFiles(e.dataTransfer.files);
};
const handleDragOver = (e) => {
e.preventDefault();
};
// Click handler
const handleClick = () => {
fileInput.value?.click();
};
// File input change handler
const handleChange = (e) => {
handleFiles(e.target.files);
};
// Format file size
const formatFileSize = (bytes) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// Watch for external value changes
watch(
() => props.context.value,
(newValue) => {
if (newValue === null || newValue === undefined) {
currentFiles.value = [];
} else if (multiple.value && Array.isArray(newValue)) {
currentFiles.value = newValue;
} else if (!multiple.value) {
currentFiles.value = [newValue];
}
},
{ immediate: true }
);
</script>
<template>
<div
:class="[
context.classes.dropzone,
isDragging ? 'border-primary ring-2 ring-primary/20' : '',
context.disabled ? 'opacity-50 cursor-not-allowed' : '',
]"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@dragover="handleDragOver"
@drop="handleDrop"
@click="handleClick"
>
<input
ref="fileInput"
type="file"
:accept="accept"
:multiple="multiple"
class="hidden"
@change="handleChange"
:disabled="context.disabled"
/>
<!-- Upload Area -->
<div
class="flex flex-col items-center justify-center gap-4 p-4 border border-dashed rounded-lg cursor-pointer"
>
<div class="rounded-full bg-muted p-4">
<Icon name="ph:upload-simple" class="h-6 w-6 text-muted-foreground" />
</div>
<div class="text-center space-y-2">
<div class="space-y-1">
<p class="text-sm font-medium">
{{ isDragging ? "Drop files here" : "Upload your files" }}
</p>
<p class="text-sm text-muted-foreground">
Drag & drop or click to select
</p>
</div>
<div class="text-xs text-muted-foreground space-y-1">
<p>Maximum file size: {{ formatFileSize(maxSize) }}</p>
<p v-if="accept">Accepted files: {{ accept }}</p>
</div>
</div>
</div>
<!-- File Preview -->
<div v-if="currentFiles.length > 0" class="border-t mt-4 pt-4">
<div class="space-y-2">
<div
v-for="file in currentFiles"
:key="file.name"
class="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2"
>
<div class="flex items-center gap-2 min-w-0">
<Icon
name="ph:file-text"
class="h-4 w-4 flex-shrink-0 text-muted-foreground"
/>
<div class="min-w-0 flex-1">
<p class="text-sm truncate">{{ file.name }}</p>
<p class="text-xs text-muted-foreground">
{{ formatFileSize(file.size) }}
</p>
</div>
</div>
<button
type="button"
class="ml-2 rounded-md p-1 hover:bg-background flex items-center justify-center"
@click.stop="removeFile(file)"
aria-label="Remove file"
>
<Icon
name="ph:x"
class="h-4 w-4 text-muted-foreground hover:text-foreground"
/>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,105 @@
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
context: {
type: Object,
required: true,
},
});
const input = ref(null);
const inputValue = ref("");
// Get mask pattern from context
const mask = computed(() => props.context.mask || "");
// Format value according to mask
const formatValue = (value) => {
if (!value) return "";
let formatted = "";
let valueIndex = 0;
for (let i = 0; i < mask.value.length && valueIndex < value.length; i++) {
const maskChar = mask.value[i];
const valueChar = value[valueIndex];
if (maskChar === "#") {
// Only allow numbers
if (/\d/.test(valueChar)) {
formatted += valueChar;
valueIndex++;
}
} else if (maskChar === "A") {
// Only allow letters
if (/[a-zA-Z]/.test(valueChar)) {
formatted += valueChar;
valueIndex++;
}
} else if (maskChar === "*") {
// Allow any character
formatted += valueChar;
valueIndex++;
} else {
// Add mask character
formatted += maskChar;
if (valueChar === maskChar) {
valueIndex++;
}
}
}
return formatted;
};
// Handle input changes
const handleInput = (event) => {
const value = event.target.value.replace(/\D+/g, "");
const formatted = formatValue(value);
inputValue.value = formatted;
props.context.node.input(formatted);
};
// Handle keydown for backspace
const handleKeydown = (event) => {
if (event.key === "Backspace" && input.value) {
event.preventDefault();
const selectionStart = input.value.selectionStart;
const selectionEnd = input.value.selectionEnd;
if (selectionStart === selectionEnd) {
const value = inputValue.value.slice(0, -1);
const formatted = formatValue(value.replace(/\D+/g, ""));
inputValue.value = formatted;
props.context.node.input(formatted);
} else {
const before = inputValue.value.slice(0, selectionStart);
const after = inputValue.value.slice(selectionEnd);
const formatted = formatValue((before + after).replace(/\D+/g, ""));
inputValue.value = formatted;
props.context.node.input(formatted);
}
}
};
// Watch for external value changes
watch(() => props.context.value, (newValue) => {
if (newValue !== inputValue.value) {
inputValue.value = formatValue(String(newValue || "").replace(/\D+/g, ""));
}
}, { immediate: true });
</script>
<template>
<input
ref="input"
type="text"
:value="inputValue"
:placeholder="context.placeholder || mask"
:disabled="context.disabled"
:class="context.classes.input"
@input="handleInput"
@keydown="handleKeydown"
/>
</template>

View File

@ -0,0 +1,101 @@
<script setup>
import { computed, ref, nextTick } from "vue";
const props = defineProps({
context: {
type: Object,
required: true,
},
});
const digits = ref([]);
const inputs = ref([]);
// Create an array of empty strings based on length
const length = computed(() => props.context.length || 6);
// Initialize digits array when component mounts
onMounted(() => {
digits.value = Array(length.value).fill("");
});
// Update the FormKit value when digits change
watch(
digits,
(newDigits) => {
const value = newDigits.join("");
props.context.node.input(value);
},
{ deep: true }
);
// Handle input on each digit
const handleInput = async (index, event) => {
const value = event.target.value;
const lastChar = value.slice(-1);
// Only allow numbers
if (!/^\d*$/.test(lastChar)) {
digits.value[index] = "";
return;
}
// Update the digit
digits.value[index] = lastChar;
// Move to next input if available
if (lastChar && index < length.value - 1) {
await nextTick();
inputs.value[index + 1]?.focus();
}
};
// Handle backspace
const handleKeydown = async (index, event) => {
if (event.key === "Backspace" && !digits.value[index]) {
// Move to previous input if current is empty
if (index > 0) {
await nextTick();
inputs.value[index - 1]?.focus();
digits.value[index - 1] = "";
}
}
};
// Handle paste
const handlePaste = (event) => {
event.preventDefault();
const pastedData = event.clipboardData.getData("text");
const numbers = pastedData.replace(/\D/g, "").split("");
numbers.forEach((num, index) => {
if (index < length.value) {
digits.value[index] = num;
}
});
// Focus the next empty input or the last input
const nextEmptyIndex = digits.value.findIndex((d) => !d);
const focusIndex = nextEmptyIndex === -1 ? length.value - 1 : nextEmptyIndex;
inputs.value[focusIndex]?.focus();
};
</script>
<template>
<div :class="context.classes.inner" @paste="handlePaste">
<template v-for="(digit, index) in length" :key="index">
<input
ref="inputs"
type="text"
inputmode="numeric"
:value="digits[index]"
:class="context.classes.digit"
:disabled="context.disabled"
:aria-label="`Digit ${index + 1}`"
@input="handleInput(index, $event)"
@keydown="handleKeydown(index, $event)"
maxlength="1"
/>
</template>
</div>
</template>

View File

@ -0,0 +1,46 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
context: {
type: Object,
required: true,
},
});
const checked = computed({
get: () => props.context.value ?? false,
set: (value) => props.context.node.input(value),
});
const id = computed(() => props.context.id);
// Handle keyboard events for accessibility
const handleKeyDown = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
checked.value = !checked.value;
}
};
</script>
<template>
<button
type="button"
role="switch"
:id="id"
:aria-checked="checked"
:disabled="context.disabled"
:tabindex="context.disabled ? -1 : 0"
class="relative inline-flex h-[20px] w-[36px] flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
:class="[checked ? 'bg-primary' : 'bg-muted']"
@click="checked = !checked"
@keydown="handleKeyDown"
>
<span
aria-hidden="true"
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-background shadow-lg ring-0 transition duration-200 ease-in-out"
:class="[checked ? 'translate-x-4' : 'translate-x-0']"
/>
</button>
</template>

View File

@ -0,0 +1,123 @@
<script setup>
import { inject, ref, computed, onBeforeUnmount } from "vue";
defineOptions({ name: "HoverCardContent" });
const props = defineProps({
side: {
type: String,
default: "bottom",
validator: (value) => ["top", "right", "bottom", "left"].includes(value),
},
align: {
type: String,
default: "center",
validator: (value) => ["start", "center", "end"].includes(value),
},
});
const { isOpen, position, triggerRef, hide, setIsHoveringContent } = inject("hover-card");
const contentRef = ref(null);
const adjustedPosition = computed(() => {
if (!contentRef.value || !triggerRef.value) return position.value;
const content = contentRef.value;
const trigger = triggerRef.value;
const contentRect = content.getBoundingClientRect();
const triggerRect = trigger.getBoundingClientRect();
const windowHeight = window.innerHeight;
const windowWidth = window.innerWidth;
let { x, y } = position.value;
// Check if there's room below
const hasSpaceBelow = triggerRect.bottom + contentRect.height <= windowHeight;
// If no space below, show above
if (!hasSpaceBelow && props.side === "bottom") {
y = triggerRect.top - contentRect.height - 8;
}
// Adjust based on side with safe area consideration
if (props.side === "top") {
y = triggerRect.top - contentRect.height - 8;
} else if (props.side === "right") {
x = triggerRect.right + 8;
y = Math.min(y, triggerRect.bottom - 20);
} else if (props.side === "left") {
x = triggerRect.left - contentRect.width - 8;
y = Math.min(y, triggerRect.bottom - 20);
} else {
x = x - 20;
}
// Adjust based on alignment
if (props.align === "start") {
// No adjustment needed
} else if (props.align === "center") {
x = triggerRect.left + triggerRect.width / 2 - contentRect.width / 2;
} else if (props.align === "end") {
x = triggerRect.right - contentRect.width;
}
// Ensure the content stays within viewport
if (x + contentRect.width > windowWidth) {
x = windowWidth - contentRect.width - 8;
}
if (x < 8) x = 8;
if (y < 8) y = 8;
if (y + contentRect.height > windowHeight) {
y = windowHeight - contentRect.height - 8;
}
return { x, y };
});
const handleMouseEnter = () => {
setIsHoveringContent(true);
};
const handleMouseLeave = () => {
setIsHoveringContent(false);
};
</script>
<template>
<Transition name="hover-card">
<div
v-if="isOpen"
ref="contentRef"
class="absolute z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in zoom-in-90"
:class="{
'data-[side=bottom]:slide-in-from-top-2': side === 'bottom',
'data-[side=left]:slide-in-from-right-2': side === 'left',
'data-[side=right]:slide-in-from-left-2': side === 'right',
'data-[side=top]:slide-in-from-bottom-2': side === 'top',
}"
:style="{
position: 'fixed',
top: `${adjustedPosition.y}px`,
left: `${adjustedPosition.x}px`,
}"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<slot />
</div>
</Transition>
</template>
<style scoped>
.hover-card-enter-active,
.hover-card-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.hover-card-enter-from,
.hover-card-leave-to {
opacity: 0;
transform: scale(0.95);
}
</style>

View File

@ -0,0 +1,85 @@
<script setup>
import { provide, ref, watch, onBeforeUnmount } from 'vue';
defineOptions({ name: 'HoverCard' });
const isOpen = ref(false);
const position = ref({ x: 0, y: 0 });
const triggerRef = ref(null);
const isHoveringTrigger = ref(false);
const isHoveringContent = ref(false);
const originalOverflow = ref('');
const lockScroll = () => {
originalOverflow.value = document.body.style.overflow;
document.body.style.overflow = 'hidden';
};
const unlockScroll = () => {
document.body.style.overflow = originalOverflow.value;
};
// Watch for changes in isOpen to handle scroll locking
watch(isOpen, (newValue) => {
if (newValue) {
lockScroll();
} else {
unlockScroll();
}
});
const show = () => {
if (triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect();
position.value = {
x: rect.left,
y: rect.bottom + 4,
};
isOpen.value = true;
}
};
const hide = () => {
// Only hide if neither trigger nor content is being hovered
if (!isHoveringTrigger.value && !isHoveringContent.value) {
isOpen.value = false;
}
};
const setIsHoveringTrigger = (value) => {
isHoveringTrigger.value = value;
if (!value && !isHoveringContent.value) {
hide();
}
};
const setIsHoveringContent = (value) => {
isHoveringContent.value = value;
if (!value && !isHoveringTrigger.value) {
hide();
}
};
provide('hover-card', {
isOpen,
position,
triggerRef,
show,
hide,
setIsHoveringContent,
setIsHoveringTrigger,
});
// Cleanup on component destruction
onBeforeUnmount(() => {
if (isOpen.value) {
unlockScroll();
}
});
</script>
<template>
<div class="relative inline-block">
<slot />
</div>
</template>

View File

@ -0,0 +1,41 @@
<script setup>
import { inject, onBeforeUnmount } from 'vue';
defineOptions({ name: 'HoverCardTrigger' });
const props = defineProps({
delay: {
type: Number,
default: 200,
},
});
const { triggerRef, show, setIsHoveringTrigger } = inject('hover-card');
let timeout;
const handleMouseEnter = () => {
clearTimeout(timeout);
setIsHoveringTrigger(true);
timeout = setTimeout(show, props.delay);
};
const handleMouseLeave = () => {
clearTimeout(timeout);
setIsHoveringTrigger(false);
};
onBeforeUnmount(() => {
clearTimeout(timeout);
});
</script>
<template>
<div
ref="triggerRef"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<slot />
</div>
</template>

11
components/modal/body.vue Normal file
View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "ModalBody",
});
</script>
<template>
<div class="relative pt-0">
<slot />
</div>
</template>

View File

@ -0,0 +1,18 @@
<script setup>
defineOptions({
name: "ModalClose",
});
const closeModal = inject("closeModal");
</script>
<template>
<button
type="button"
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
@click="closeModal"
>
<Icon name="ph:x" class="h-4 w-4" />
<span class="sr-only">Close</span>
</button>
</template>

View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "ModalDescription",
});
</script>
<template>
<p class="text-sm text-muted-foreground">
<slot />
</p>
</template>

View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "ModalFooter",
});
</script>
<template>
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
<slot />
</div>
</template>

View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "ModalHeader",
});
</script>
<template>
<div class="flex flex-col space-y-1.5 text-center sm:text-left mb-4">
<slot />
</div>
</template>

129
components/modal/index.vue Normal file
View File

@ -0,0 +1,129 @@
<script setup>
defineOptions({
name: "Modal",
});
const props = defineProps({
open: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'xl',
validator: (value) => ['sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', 'full'].includes(value) || /^\d+px$/.test(value) || /^\d+rem$/.test(value),
},
});
const emit = defineEmits(["update:open"]);
const originalOverflow = ref("");
const modalSize = computed(() => {
const sizes = {
sm: '384px',
md: '448px',
lg: '512px',
xl: '576px',
'2xl': '672px',
'3xl': '768px',
'4xl': '896px',
'5xl': '1024px',
'full': '100%'
};
// If it's a predefined size, return the corresponding width
if (sizes[props.size]) {
return sizes[props.size];
}
// If it's a custom size (e.g., '600px' or '20rem'), return it directly
if (/^\d+px$/.test(props.size) || /^\d+rem$/.test(props.size)) {
return props.size;
}
// Default fallback
return sizes.xl;
});
const lockScroll = () => {
originalOverflow.value = document.body.style.overflow;
document.body.style.overflow = "hidden";
};
const unlockScroll = () => {
document.body.style.overflow = originalOverflow.value;
};
const close = () => {
emit("update:open", false);
};
// Watch for changes in open state to handle scroll locking
watch(
() => props.open,
(newValue) => {
if (newValue) {
lockScroll();
} else {
unlockScroll();
}
}
);
// Close on escape key and handle scroll locking
onMounted(() => {
const handleEscape = (e) => {
if (e.key === "Escape" && props.open) close();
};
document.addEventListener("keydown", handleEscape);
// Initial scroll lock if modal is open on mount
if (props.open) {
lockScroll();
}
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleEscape);
// Ensure we unlock scroll when component is destroyed
if (props.open) {
unlockScroll();
}
});
});
provide("closeModal", close);
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="open" class="fixed inset-0 z-[9999]">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/70"
@click="close"
/>
<!-- Modal Content -->
<div
class="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%]"
>
<div class="w-full mx-auto px-4 sm:px-0" :style="{ width: modalSize }">
<div
class="relative rounded-lg border bg-background p-6 shadow-lg"
@click.stop
>
<slot />
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({
name: "ModalTitle",
});
</script>
<template>
<h2 class="text-lg font-semibold leading-none tracking-tight">
<slot />
</h2>
</template>

View File

@ -0,0 +1,239 @@
<script setup>
import { inject, ref, computed } from "vue";
defineOptions({ name: "PopoverContent" });
const props = defineProps({
side: {
type: String,
default: "bottom",
validator: (value) => ["top", "right", "bottom", "left"].includes(value),
},
align: {
type: String,
default: "center",
validator: (value) => ["start", "center", "end"].includes(value),
},
sideOffset: {
type: Number,
default: 4,
},
alignOffset: {
type: Number,
default: 0,
},
});
const { isOpen, position, triggerRef } = inject("popover");
const contentRef = ref(null);
const adjustedPosition = computed(() => {
if (!contentRef.value || !triggerRef.value) return position.value;
const content = contentRef.value;
const trigger = triggerRef.value;
const contentRect = content.getBoundingClientRect();
const triggerRect = trigger.getBoundingClientRect();
const windowHeight = window.innerHeight;
const windowWidth = window.innerWidth;
let { x, y } = position.value;
let adjustedSide = props.side;
// Check available space in all directions
const spaceAbove = triggerRect.top;
const spaceBelow = windowHeight - triggerRect.bottom;
const spaceLeft = triggerRect.left;
const spaceRight = windowWidth - triggerRect.right;
// Automatically switch sides if there's not enough space
if (
adjustedSide === "bottom" &&
spaceBelow < contentRect.height &&
spaceAbove > spaceBelow
) {
adjustedSide = "top";
} else if (
adjustedSide === "top" &&
spaceAbove < contentRect.height &&
spaceBelow > spaceAbove
) {
adjustedSide = "bottom";
} else if (
adjustedSide === "right" &&
spaceRight < contentRect.width &&
spaceLeft > spaceRight
) {
adjustedSide = "left";
} else if (
adjustedSide === "left" &&
spaceLeft < contentRect.width &&
spaceRight > spaceLeft
) {
adjustedSide = "right";
}
// Position based on adjusted side
switch (adjustedSide) {
case "top":
y = triggerRect.top - contentRect.height - props.sideOffset;
break;
case "right":
x = triggerRect.right + props.sideOffset;
break;
case "bottom":
y = triggerRect.bottom + props.sideOffset;
break;
case "left":
x = triggerRect.left - contentRect.width - props.sideOffset;
break;
}
// Adjust alignment
switch (props.align) {
case "start":
if (adjustedSide === "top" || adjustedSide === "bottom") {
x = triggerRect.left + props.alignOffset;
} else {
y = triggerRect.top + props.alignOffset;
}
break;
case "center":
if (adjustedSide === "top" || adjustedSide === "bottom") {
x =
triggerRect.left +
triggerRect.width / 2 -
contentRect.width / 2 +
props.alignOffset;
} else {
y =
triggerRect.top +
triggerRect.height / 2 -
contentRect.height / 2 +
props.alignOffset;
}
break;
case "end":
if (adjustedSide === "top" || adjustedSide === "bottom") {
x = triggerRect.right - contentRect.width + props.alignOffset;
} else {
y = triggerRect.bottom - contentRect.height + props.alignOffset;
}
break;
}
// Ensure the content stays within viewport bounds with padding
const VIEWPORT_PADDING = 8;
if (x + contentRect.width > windowWidth - VIEWPORT_PADDING) {
x = windowWidth - contentRect.width - VIEWPORT_PADDING;
}
if (x < VIEWPORT_PADDING) {
x = VIEWPORT_PADDING;
}
if (y + contentRect.height > windowHeight - VIEWPORT_PADDING) {
y = windowHeight - contentRect.height - VIEWPORT_PADDING;
}
if (y < VIEWPORT_PADDING) {
y = VIEWPORT_PADDING;
}
return { x, y, adjustedSide };
});
</script>
<template>
<Transition name="popover">
<div
v-if="isOpen"
ref="contentRef"
class="popover-content absolute z-50 min-w-[8rem] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in"
:class="{
'slide-in-from-top-2': adjustedPosition.adjustedSide === 'bottom',
'slide-in-from-right-2': adjustedPosition.adjustedSide === 'left',
'slide-in-from-left-2': adjustedPosition.adjustedSide === 'right',
'slide-in-from-bottom-2': adjustedPosition.adjustedSide === 'top',
}"
:style="{
position: 'fixed',
top: `${adjustedPosition.y}px`,
left: `${adjustedPosition.x}px`,
}"
>
<slot />
</div>
</Transition>
</template>
<style scoped>
.popover-enter-active,
.popover-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.popover-enter-from,
.popover-leave-to {
opacity: 0;
transform: scale(0.95);
}
.slide-in-from-top-2 {
animation: slideInFromTop 0.2s ease;
}
.slide-in-from-right-2 {
animation: slideInFromRight 0.2s ease;
}
.slide-in-from-bottom-2 {
animation: slideInFromBottom 0.2s ease;
}
.slide-in-from-left-2 {
animation: slideInFromLeft 0.2s ease;
}
@keyframes slideInFromTop {
from {
opacity: 0;
transform: translateY(-0.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInFromRight {
from {
opacity: 0;
transform: translateX(0.5rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInFromBottom {
from {
opacity: 0;
transform: translateY(0.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInFromLeft {
from {
opacity: 0;
transform: translateX(-0.5rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>

View File

@ -0,0 +1,74 @@
<script setup>
import { provide, ref, watch } from 'vue';
defineOptions({ name: 'Popover' });
const isOpen = ref(false);
const position = ref({ x: 0, y: 0 });
const triggerRef = ref(null);
const originalOverflow = ref('');
const lockScroll = () => {
originalOverflow.value = document.body.style.overflow;
document.body.style.overflow = 'hidden';
};
const unlockScroll = () => {
document.body.style.overflow = originalOverflow.value;
};
// Watch for changes in isOpen to handle scroll locking
watch(isOpen, (newValue) => {
if (newValue) {
lockScroll();
} else {
unlockScroll();
}
});
const toggle = () => {
isOpen.value = !isOpen.value;
if (isOpen.value && triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect();
position.value = {
x: rect.left,
y: rect.bottom + 4,
};
}
};
const close = () => {
isOpen.value = false;
};
provide('popover', {
isOpen,
position,
triggerRef,
toggle,
close,
});
onMounted(() => {
document.addEventListener('click', (event) => {
const target = event.target;
if (!triggerRef.value?.contains(target) && !event.target.closest('.popover-content')) {
close();
}
});
});
onBeforeUnmount(() => {
document.removeEventListener('click', close);
// Ensure we unlock scroll when component is destroyed
if (isOpen.value) {
unlockScroll();
}
});
</script>
<template>
<div class="relative inline-block">
<slot />
</div>
</template>

View File

@ -0,0 +1,13 @@
<script setup>
import { inject } from 'vue';
defineOptions({ name: 'PopoverTrigger' });
const { triggerRef, toggle } = inject('popover');
</script>
<template>
<div ref="triggerRef" @click.stop="toggle">
<slot />
</div>
</template>

View File

@ -0,0 +1,132 @@
<script setup>
defineOptions({ name: "Progress" });
const props = defineProps({
value: {
type: Number,
required: true,
validator: (value) => value >= 0 && value <= 100,
},
size: {
type: String,
default: "default",
validator: (value) => ["sm", "default", "lg"].includes(value),
},
showValue: {
type: Boolean,
default: false,
},
variant: {
type: String,
default: "default",
validator: (value) =>
["default", "success", "info", "warning", "danger"].includes(value),
},
animated: {
type: Boolean,
default: true,
},
striped: {
type: Boolean,
default: false,
},
});
const sizeClasses = {
sm: "!h-2",
default: "!h-4",
lg: "!h-6",
};
const variantClasses = {
default: "bg-primary",
success: "bg-[rgb(var(--success))]",
info: "bg-[rgb(var(--info))]",
warning: "bg-[rgb(var(--warning))]",
danger: "bg-[rgb(var(--danger))]",
};
const textVariantClasses = {
default: "!text-primary-foreground",
success: "!text-[rgb(var(--success-foreground))]",
info: "!text-[rgb(var(--info-foreground))]",
warning: "!text-[rgb(var(--warning-foreground))]",
danger: "!text-[rgb(var(--danger-foreground))]",
};
const progressClasses = computed(() => {
return [
sizeClasses[props.size],
variantClasses[props.variant],
{
"animate-progress": props.animated,
"bg-striped": props.striped,
},
];
});
const valueStyles = computed(() => ({
width: `${props.value}%`,
transition: props.animated ? "width 0.3s ease-in-out" : "none",
}));
</script>
<template>
<div class="relative w-full">
<div
class="relative h-full w-full overflow-hidden rounded-full bg-secondary"
role="progressbar"
:aria-valuenow="value"
aria-valuemin="0"
aria-valuemax="100"
>
<div
:class="progressClasses"
class="h-full w-full flex-1 rounded-full transition-all"
:style="valueStyles"
/>
</div>
<div
v-if="showValue"
class="absolute inset-0 flex items-center justify-center text-xs font-medium"
:class="textVariantClasses[variant]"
>
{{ value }}%
</div>
</div>
</template>
<style scoped>
.bg-striped {
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.15) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.15) 50%,
rgba(255, 255, 255, 0.15) 75%,
transparent 75%,
transparent
);
background-size: 1rem 1rem;
}
.animate-progress {
animation: progress-animation 1s linear infinite;
}
@keyframes progress-animation {
from {
background-position: 1rem 0;
}
to {
background-position: 0 0;
}
}
/* Ensure text is visible on all backgrounds */
.text-xs {
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
color: white;
}
</style>

View File

@ -0,0 +1,206 @@
<script setup>
import { ref, onMounted, computed } from 'vue';
defineOptions({ name: 'ScrollArea' });
const props = defineProps({
type: {
type: String,
default: 'hover',
validator: (value) => ['always', 'scroll', 'hover', 'auto'].includes(value),
},
orientation: {
type: String,
default: 'vertical',
validator: (value) => ['vertical', 'horizontal', 'both'].includes(value),
},
scrollHideDelay: {
type: Number,
default: 600,
},
});
const viewport = ref(null);
const scrollbar = ref(null);
const thumb = ref(null);
let isDragging = false;
let startPosition = { x: 0, y: 0 };
let startScroll = { left: 0, top: 0 };
let hideScrollbarTimeout;
const showScrollbar = ref(props.type === 'always');
const isVertical = computed(() => props.orientation !== 'horizontal');
const isHorizontal = computed(() => props.orientation !== 'vertical');
const handleMouseEnter = () => {
if (props.type === 'hover') {
showScrollbar.value = true;
}
};
const handleMouseLeave = () => {
if (props.type === 'hover' && !isDragging) {
showScrollbar.value = false;
}
};
const handleScroll = () => {
if (props.type === 'scroll') {
showScrollbar.value = true;
clearTimeout(hideScrollbarTimeout);
hideScrollbarTimeout = setTimeout(() => {
if (!isDragging) showScrollbar.value = false;
}, props.scrollHideDelay);
}
updateThumbPosition();
};
const updateThumbPosition = () => {
if (!viewport.value || !thumb.value) return;
const {
scrollTop,
scrollLeft,
scrollHeight,
scrollWidth,
clientHeight,
clientWidth,
} = viewport.value;
if (isVertical.value) {
const thumbHeight = (clientHeight / scrollHeight) * clientHeight;
const thumbPosition = (scrollTop / scrollHeight) * clientHeight;
thumb.value.style.height = `${thumbHeight}px`;
thumb.value.style.transform = `translateY(${thumbPosition}px)`;
thumb.value.style.width = '6px';
}
if (isHorizontal.value) {
const thumbWidth = (clientWidth / scrollWidth) * clientWidth;
const thumbPosition = (scrollLeft / scrollWidth) * clientWidth;
thumb.value.style.width = `${thumbWidth}px`;
thumb.value.style.transform = `translateX(${thumbPosition}px)`;
thumb.value.style.height = '6px';
}
};
const handleThumbMousedown = (e) => {
e.preventDefault();
isDragging = true;
startPosition = { x: e.clientX, y: e.clientY };
startScroll = {
left: viewport.value.scrollLeft,
top: viewport.value.scrollTop,
};
document.addEventListener('mousemove', handleThumbMousemove);
document.addEventListener('mouseup', handleThumbMouseup);
};
const handleThumbMousemove = (e) => {
if (!isDragging) return;
const { scrollHeight, scrollWidth, clientHeight, clientWidth } = viewport.value;
if (isVertical.value) {
const delta = e.clientY - startPosition.y;
const scrollFactor = scrollHeight / clientHeight;
viewport.value.scrollTop = startScroll.top + delta * scrollFactor;
}
if (isHorizontal.value) {
const delta = e.clientX - startPosition.x;
const scrollFactor = scrollWidth / clientWidth;
viewport.value.scrollLeft = startScroll.left + delta * scrollFactor;
}
};
const handleThumbMouseup = () => {
isDragging = false;
document.removeEventListener('mousemove', handleThumbMousemove);
document.removeEventListener('mouseup', handleThumbMouseup);
if (props.type === 'hover') {
handleMouseLeave();
}
};
onMounted(() => {
updateThumbPosition();
});
onBeforeUnmount(() => {
clearTimeout(hideScrollbarTimeout);
});
</script>
<template>
<div
class="relative"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div
ref="viewport"
class="scrollarea-viewport h-full w-full overflow-auto"
:class="{
'overflow-y-auto overflow-x-hidden': orientation === 'vertical',
'overflow-x-auto overflow-y-hidden': orientation === 'horizontal',
'overflow-auto': orientation === 'both',
}"
@scroll="handleScroll"
>
<div class="scrollarea-content">
<slot />
</div>
</div>
<div
v-show="showScrollbar"
ref="scrollbar"
class="scrollarea-scrollbar touch-none select-none transition-colors"
:class="{
'right-0.5 top-0.5 bottom-0.5 w-2': orientation === 'vertical',
'bottom-0.5 left-0.5 right-0.5 h-2': orientation === 'horizontal',
}"
>
<div
ref="thumb"
class="scrollarea-thumb relative rounded-full bg-border hover:bg-foreground/50"
@mousedown="handleThumbMousedown"
/>
</div>
</div>
</template>
<style scoped>
.scrollarea-viewport {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollarea-viewport::-webkit-scrollbar {
display: none;
}
.scrollarea-scrollbar {
position: absolute;
background-color: transparent;
z-index: 10;
}
.scrollarea-thumb {
position: absolute;
background-color: rgb(var(--border));
cursor: pointer;
border-radius: 9999px;
}
.scrollarea-thumb:hover {
background-color: rgb(var(--foreground), 0.5);
}
.scrollarea-viewport {
-webkit-overflow-scrolling: touch;
}
</style>

View File

@ -0,0 +1,39 @@
<script setup>
defineOptions({ name: 'Separator' });
const props = defineProps({
orientation: {
type: String,
default: 'horizontal',
validator: (value) => ['horizontal', 'vertical'].includes(value),
},
decorative: {
type: Boolean,
default: true,
},
});
// Compute ARIA attributes based on decorative prop
const ariaAttributes = computed(() => {
if (props.decorative) {
return {
role: 'none',
'aria-hidden': 'true',
};
}
return {
role: 'separator',
'aria-orientation': props.orientation,
};
});
</script>
<template>
<div
v-bind="ariaAttributes"
:class="[
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
]"
/>
</template>

View File

@ -0,0 +1,59 @@
<script setup>
defineOptions({ name: 'Skeleton' });
const props = defineProps({
className: {
type: String,
default: '',
},
variant: {
type: String,
default: 'default',
validator: (value) => ['default', 'card', 'avatar', 'title', 'text'].includes(value),
},
animate: {
type: Boolean,
default: true,
},
});
const variantClasses = computed(() => {
switch (props.variant) {
case 'card':
return 'h-48 w-full rounded-lg';
case 'avatar':
return 'h-12 w-12 rounded-full';
case 'title':
return 'h-4 w-1/4 rounded-lg';
case 'text':
return 'h-4 w-full rounded-lg';
default:
return props.className || 'h-4 w-full rounded-lg';
}
});
const classes = computed(() => [
'bg-muted animate-pulse',
variantClasses.value,
{ 'animate-none': !props.animate },
]);
</script>
<template>
<div :class="classes" />
</template>
<style scoped>
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
</style>

View File

@ -0,0 +1,9 @@
<script setup>
defineOptions({ name: 'StepperDescription' });
</script>
<template>
<p class="text-sm text-muted-foreground">
<slot />
</p>
</template>

View File

@ -0,0 +1,52 @@
<script setup>
import { provide, ref, watch } from 'vue';
defineOptions({ name: 'Stepper' });
const props = defineProps({
modelValue: {
type: Number,
default: 1
},
orientation: {
type: String,
default: 'horizontal',
validator: (value) => ['horizontal', 'vertical'].includes(value)
}
});
const emit = defineEmits(['update:modelValue']);
const currentStep = ref(props.modelValue);
// Create stepper context
const stepperContext = {
currentStep,
updateStep: (step) => {
currentStep.value = step;
emit('update:modelValue', step);
},
orientation: props.orientation
};
// Provide stepper context to child components
provide('stepper', stepperContext);
// Watch for external changes to modelValue
watch(() => props.modelValue, (newValue) => {
currentStep.value = newValue;
});
</script>
<template>
<div
:class="[
'flex',
orientation === 'horizontal'
? 'flex-row items-center space-x-1'
: 'flex-col space-y-1'
]"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,34 @@
<script setup>
import { inject, computed } from "vue";
defineOptions({ name: "StepperIndicator" });
const props = defineProps({
step: {
type: Number,
required: true,
},
});
const { currentStep } = inject("stepper");
const status = computed(() => {
if (currentStep.value === props.step) return "current";
if (currentStep.value > props.step) return "complete";
return "pending";
});
</script>
<template>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-full border-2 transition-colors',
status === 'complete' &&
'bg-primary border-primary text-primary-foreground',
status === 'current' && 'border-primary text-primary',
status === 'pending' && ' text-muted-foreground',
]"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,40 @@
<script setup>
import { inject, computed } from 'vue';
defineOptions({ name: 'StepperItem' });
const props = defineProps({
step: {
type: Number,
required: true
},
disabled: {
type: Boolean,
default: false
}
});
const stepper = inject('stepper');
const status = computed(() => {
if (!stepper) return 'pending';
if (stepper.currentStep.value === props.step) return 'current';
if (stepper.currentStep.value > props.step) return 'complete';
return 'pending';
});
const orientation = computed(() => stepper?.orientation || 'horizontal');
</script>
<template>
<div
:class="[
'relative flex',
orientation === 'horizontal' ? 'items-center' : 'flex-col',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
]"
:data-status="status"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,19 @@
<script setup>
import { inject, computed } from 'vue';
defineOptions({ name: 'StepperSeparator' });
const stepper = inject('stepper');
const orientation = computed(() => stepper?.orientation || 'horizontal');
</script>
<template>
<div
:class="[
'transition-colors',
orientation === 'horizontal'
? 'h-[2px] w-10 bg-border mx-1'
: 'w-[2px] h-10 bg-border my-1 ml-4'
]"
/>
</template>

View File

@ -0,0 +1,9 @@
<script setup>
defineOptions({ name: 'StepperTitle' });
</script>
<template>
<h3 class="font-medium leading-none">
<slot />
</h3>
</template>

View File

@ -0,0 +1,28 @@
<script setup>
import { inject } from 'vue';
defineOptions({ name: 'StepperTrigger' });
const props = defineProps({
step: {
type: Number,
required: true
}
});
const { updateStep } = inject('stepper');
const handleClick = () => {
updateStep(props.step);
};
</script>
<template>
<div
class="flex items-center gap-2 group"
role="button"
@click="handleClick"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,9 @@
<script setup>
defineOptions({ name: "TableBody" });
</script>
<template>
<tbody class="[&_tr:last-child]:border-0">
<slot />
</tbody>
</template>

View File

@ -0,0 +1,9 @@
<script setup>
defineOptions({ name: "TableCell" });
</script>
<template>
<td class="px-4 py-2 align-middle">
<slot />
</td>
</template>

11
components/table/head.vue Normal file
View File

@ -0,0 +1,11 @@
<script setup>
defineOptions({ name: "TableHead" });
</script>
<template>
<th
class="h-12 px-4 text-left align-middle font-medium text-muted-foreground"
>
<slot />
</th>
</template>

View File

@ -0,0 +1,9 @@
<script setup>
defineOptions({ name: "TableHeader" });
</script>
<template>
<thead class="[&_tr]:border-b bg-muted/50">
<slot />
</thead>
</template>

View File

@ -0,0 +1,18 @@
<script setup>
defineOptions({ name: "Table" });
const props = defineProps({
hover: {
type: Boolean,
default: true,
},
});
</script>
<template>
<div class="w-full overflow-auto">
<table class="w-full caption-bottom text-sm">
<slot />
</table>
</div>
</template>

9
components/table/row.vue Normal file
View File

@ -0,0 +1,9 @@
<script setup>
defineOptions({ name: "TableRow" });
</script>
<template>
<tr class="border-b transition-colors hover:bg-muted/50">
<slot />
</tr>
</template>

View File

@ -0,0 +1,71 @@
<script setup>
import { inject, computed } from 'vue';
defineOptions({ name: 'TabsContent' });
const props = defineProps({
value: {
type: [String, Number],
required: true
}
});
const { activeTab, orientation } = inject('tabs');
const isActive = computed(() => activeTab.value === props.value);
const transitionClasses = computed(() => {
const baseClasses = 'transition-all duration-300';
if (orientation === 'responsive') {
return {
[baseClasses]: true,
'translate-x-0 opacity-100': isActive.value,
'translate-x-2 opacity-0': !isActive.value,
'md:translate-x-0 md:translate-y-0': isActive.value,
'md:translate-x-0 md:translate-y-2 md:opacity-0': !isActive.value,
'hidden': !isActive.value
};
}
if (orientation === 'vertical') {
return {
[baseClasses]: true,
'translate-y-0 opacity-100': isActive.value,
'translate-y-2 opacity-0': !isActive.value,
'hidden': !isActive.value
};
}
return {
[baseClasses]: true,
'translate-x-0 opacity-100': isActive.value,
'translate-x-2 opacity-0': !isActive.value,
'hidden': !isActive.value
};
});
const contentClasses = computed(() => [
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'mt-2',
transitionClasses.value
]);
</script>
<template>
<div
role="tabpanel"
:class="contentClasses"
:tabindex="isActive ? 0 : -1"
:aria-hidden="!isActive"
>
<slot v-if="isActive" />
</div>
</template>
<style scoped>
[role="tabpanel"] {
transform-origin: top left;
will-change: transform, opacity;
}
</style>

73
components/tabs/index.vue Normal file
View File

@ -0,0 +1,73 @@
<script setup>
import { provide, ref, watch } from 'vue';
defineOptions({ name: 'Tabs' });
const props = defineProps({
modelValue: {
type: [String, Number],
required: true
},
defaultValue: {
type: [String, Number],
default: null
},
orientation: {
type: String,
default: 'horizontal',
validator: (value) => ['horizontal', 'vertical', 'responsive'].includes(value)
}
});
const emit = defineEmits(['update:modelValue']);
const activeTab = ref(props.modelValue);
watch(() => props.modelValue, (newValue) => {
activeTab.value = newValue;
}, { immediate: true });
const updateTab = (value) => {
activeTab.value = value;
emit('update:modelValue', value);
};
provide('tabs', {
activeTab,
updateTab,
orientation: props.orientation
});
</script>
<template>
<div
:class="[
'w-full',
{
'flex gap-6': orientation === 'vertical',
'block': orientation === 'horizontal',
'block md:flex md:gap-6': orientation === 'responsive'
}
]"
>
<div
:class="[
'relative',
{
'flex-1': orientation === 'vertical',
'w-full': orientation === 'horizontal',
'w-full md:flex-1': orientation === 'responsive'
}
]"
>
<slot />
</div>
</div>
</template>
<style scoped>
.relative {
perspective: 1000px;
transform-style: preserve-3d;
}
</style>

32
components/tabs/list.vue Normal file
View File

@ -0,0 +1,32 @@
<script setup>
import { inject, computed } from 'vue';
defineOptions({ name: 'TabsList' });
const { orientation } = inject('tabs');
const listClasses = computed(() => {
const baseClasses = 'inline-flex items-center justify-center rounded-[var(--radius)] bg-muted p-1 text-muted-foreground';
if (orientation === 'responsive') {
return [
baseClasses,
'h-10 w-full space-x-1',
'md:h-fit md:w-full md:flex-col md:space-x-0 md:space-y-1'
];
}
return [
baseClasses,
orientation === 'vertical'
? 'flex-col h-fit w-full space-y-1'
: 'h-10 w-full space-x-1'
];
});
</script>
<template>
<div :class="listClasses">
<slot />
</div>
</template>

View File

@ -0,0 +1,58 @@
<script setup>
import { inject, computed } from "vue";
defineOptions({ name: "TabsTrigger" });
const props = defineProps({
value: {
type: [String, Number],
required: true,
},
disabled: {
type: Boolean,
default: false,
},
});
const { activeTab, updateTab, orientation } = inject("tabs");
const isActive = computed(() => activeTab.value === props.value);
const triggerClasses = computed(() => [
"inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius)] px-3 py-1.5 text-sm font-medium ring-offset-background transition-all duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 relative",
{
"w-full": orientation === "vertical",
"flex-1": orientation === "horizontal",
"flex-1 md:w-full": orientation === "responsive",
},
isActive.value && "bg-background text-foreground shadow",
!props.disabled && "hover:text-foreground",
]);
const handleClick = () => {
if (!props.disabled) {
updateTab(props.value);
}
};
</script>
<template>
<button
type="button"
role="tab"
:disabled="disabled"
:data-state="isActive ? 'active' : 'inactive'"
:class="triggerClasses"
@click="handleClick"
>
<slot />
</button>
</template>
<style scoped>
button {
backface-visibility: hidden;
transform: translateZ(0);
-webkit-font-smoothing: subpixel-antialiased;
}
</style>

View File

@ -0,0 +1,58 @@
<script setup>
defineOptions({
name: "ThemeSwitcher",
});
const { currentTheme, setTheme } = useTheme();
const themes = [
{
value: "default",
label: "Light",
icon: "ph:sun",
},
{
value: "dark",
label: "Dark",
icon: "ph:moon",
},
];
const toggle = () => {
const currentIndex = themes.findIndex(
(theme) => theme.value === currentTheme.value
);
const nextIndex = (currentIndex + 1) % themes.length;
setTheme(themes[nextIndex].value);
};
// Watch for theme changes to save to localStorage
watch(currentTheme, (newTheme) => {
localStorage.setItem("theme", newTheme);
});
</script>
<template>
<Button
variant="ghost"
size="icon"
@click="toggle"
class="relative"
title="Toggle theme"
>
<ClientOnly>
<Icon
v-for="theme in themes"
:key="theme.value"
:name="theme.icon"
class="h-5 w-5 transition-all"
:class="{
'scale-100 duration-300 rotate-0': currentTheme === theme.value,
'scale-0 absolute duration-200 rotate-90':
currentTheme !== theme.value,
}"
/>
</ClientOnly>
<span class="sr-only">Toggle theme</span>
</Button>
</template>

100
components/toast/index.vue Normal file
View File

@ -0,0 +1,100 @@
<script setup>
import { toasts } from "~/composables/useToast";
import { TransitionGroup } from "vue";
import { computed } from "vue";
const props = defineProps({
position: {
type: String,
default: "bottom-right",
validator: (value) =>
["top-left", "top-right", "bottom-left", "bottom-right"].includes(value),
},
});
const positionClasses = {
"top-left": "top-0 left-0",
"top-right": "top-0 right-0",
"bottom-left": "bottom-0 left-0",
"bottom-right": "bottom-0 right-0",
};
// Compute animation classes based on position
const getAnimationClasses = (position) => {
const isTop = position.startsWith("top");
return {
enter: {
active: "transition ease-out duration-300",
from: `opacity-0 ${isTop ? "-translate-y-2" : "translate-y-2"}`,
to: "opacity-100 translate-y-0",
},
leave: {
active: "transition ease-in duration-200",
from: "opacity-100 translate-y-0",
to: `opacity-0 ${isTop ? "-translate-y-2" : "translate-y-2"}`,
},
};
};
const animations = computed(() => getAnimationClasses(props.position));
// Compute whether there are active toasts
const hasActiveToasts = computed(() => toasts[props.position].value.length > 0);
</script>
<template>
<div
:class="[
'fixed z-[100] flex max-h-screen w-full flex-col gap-2 p-4 sm:max-w-[420px]',
positionClasses[position],
{
'flex-col': position.startsWith('top'),
'flex-col-reverse': position.startsWith('bottom'),
'pointer-events-none': !hasActiveToasts,
},
]"
>
<TransitionGroup
:enter-active-class="animations.enter.active"
:enter-from-class="animations.enter.from"
:enter-to-class="animations.enter.to"
:leave-active-class="animations.leave.active"
:leave-from-class="animations.leave.from"
:leave-to-class="animations.leave.to"
>
<div
v-for="toast in toasts[position].value"
:key="toast.id"
class="group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all"
:class="{
'bg-background text-foreground': toast.type === 'default',
'bg-danger text-danger-foreground': toast.type === 'error',
'bg-primary text-primary-foreground': toast.type === 'success',
}"
>
<div class="flex items-start gap-2">
<Icon
v-if="toast.type === 'error'"
name="ph:x-circle"
class="h-5 w-5 text-danger-foreground"
/>
<Icon
v-if="toast.type === 'success'"
name="ph:check-circle"
class="h-5 w-5 text-primary-foreground"
/>
<div class="grid gap-1">
<div class="text-sm font-semibold">{{ toast.title }}</div>
<div class="text-sm opacity-90">{{ toast.description }}</div>
</div>
</div>
<button
class="absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100"
@click="useToast().remove(toast.id, toast.position)"
>
<Icon name="ph:x" class="h-4 w-4" />
</button>
</div>
</TransitionGroup>
</div>
</template>

View File

@ -0,0 +1,53 @@
<script setup>
import { inject, watch } from "vue";
defineOptions({ name: "TooltipContent" });
const props = defineProps({
side: {
type: String,
default: "top",
validator: (value) => ["top", "right", "bottom", "left"].includes(value),
},
sideOffset: {
type: Number,
default: 4,
},
});
const { floating, isOpen, floatingStyles, arrowRef, arrowStyles, placement } =
inject("tooltip");
</script>
<template>
<Transition
enter-active-class="transition-opacity duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
ref="floating"
:style="floatingStyles"
class="z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
:data-side="placement?.split('-')[0]"
role="tooltip"
>
<slot />
<div
ref="arrowRef"
:style="arrowStyles"
class="absolute z-[-1] h-2 w-2 rotate-45 bg-popover border"
:class="{
'-top-1 border-b border-r': placement?.includes('bottom'),
'-bottom-1 border-t border-l': placement?.includes('top'),
'-right-1 border-b border-l': placement?.includes('left'),
'-left-1 border-t border-r': placement?.includes('right')
}"
/>
</div>
</Transition>
</template>

View File

@ -0,0 +1,91 @@
<script setup>
import { useFloating, offset, flip, shift, arrow } from '@floating-ui/vue';
import { computed, ref, provide } from 'vue';
defineOptions({ name: 'Tooltip' });
const props = defineProps({
side: {
type: String,
default: 'top',
validator: (value) => ['top', 'right', 'bottom', 'left'].includes(value),
},
delayDuration: {
type: Number,
default: 200,
},
skipDelayDuration: {
type: Number,
default: 300,
},
});
const isOpen = ref(false);
const arrowRef = ref(null);
const reference = ref(null);
const floating = ref(null);
const { floatingStyles, middlewareData, placement } = useFloating(reference, floating, {
placement: computed(() => props.side || 'top'),
middleware: [
offset(8),
flip(),
shift(),
arrow({ element: arrowRef }),
],
});
const arrowStyles = computed(() => {
if (!middlewareData.value.arrow) return {};
const { x, y } = middlewareData.value.arrow;
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.value.split('-')[0]];
return {
left: x != null ? `${x}px` : '',
top: y != null ? `${y}px` : '',
[staticSide]: '-4px',
};
});
let showTimeout;
let hideTimeout;
const show = () => {
clearTimeout(hideTimeout);
showTimeout = setTimeout(() => {
isOpen.value = true;
}, props.delayDuration);
};
const hide = () => {
clearTimeout(showTimeout);
hideTimeout = setTimeout(() => {
isOpen.value = false;
}, props.skipDelayDuration);
};
provide('tooltip', {
reference,
floating,
arrowRef,
isOpen,
floatingStyles,
arrowStyles,
show,
hide,
placement,
});
</script>
<template>
<div>
<slot />
</div>
</template>

View File

@ -0,0 +1,19 @@
<script setup>
import { inject } from 'vue';
defineOptions({ name: 'TooltipTrigger' });
const { reference, show, hide } = inject('tooltip');
</script>
<template>
<div
ref="reference"
@mouseenter="show"
@mouseleave="hide"
@focus="show"
@blur="hide"
>
<slot />
</div>
</template>

166
composables/dataTable.js Normal file
View File

@ -0,0 +1,166 @@
import { watchDebounced } from "@vueuse/core";
export const useDataTable = (initialData, initialColumns) => {
const data = ref(initialData);
const columns = ref(initialColumns);
const sortBy = ref("");
const sortDesc = ref(false);
const searchQuery = ref("");
const currentPage = ref(1);
const itemsPerPage = ref(10);
const itemsPerPageOptions = ref([5, 10, 25, 50, 100]);
const columnFilters = ref({});
// Debounced search to prevent excessive filtering
const debouncedSearch = ref(searchQuery.value);
watchDebounced(
searchQuery,
(newValue) => {
debouncedSearch.value = newValue;
currentPage.value = 1;
},
{ debounce: 300 }
);
// Memoize cell value getter
const getCellValue = memoize((item, column) => {
if (column.path) {
return column.path.split(".").reduce((obj, key) => obj?.[key], item);
}
return item[column.key];
});
// Create index for faster searching
const searchIndex = computed(() => {
const index = new Map();
data.value.forEach((item, idx) => {
const searchableValues = columns.value
.map((column) => getCellValue(item, column))
.filter(Boolean)
.map((value) => value.toString().toLowerCase())
.join(" ");
index.set(idx, searchableValues);
});
return index;
});
const filteredData = computed(() => {
// Start with indices instead of full objects for better memory usage
let indices = [...data.value.keys()];
// Apply column filters
Object.entries(columnFilters.value).forEach(([key, value]) => {
if (value) {
const column = columns.value.find((col) => col.key === key);
indices = indices.filter((idx) => {
const item = data.value[idx];
const cellValue = getCellValue(item, column);
return cellValue
?.toString()
.toLowerCase()
.includes(value.toLowerCase());
});
}
});
// Apply search filter using index
if (debouncedSearch.value) {
const searchTerm = debouncedSearch.value.toLowerCase();
indices = indices.filter((idx) =>
searchIndex.value.get(idx).includes(searchTerm)
);
}
// Apply sorting only to visible page
if (sortBy.value) {
const sortColumn = columns.value.find((col) => col.key === sortBy.value);
indices.sort((aIdx, bIdx) => {
const aVal = getCellValue(data.value[aIdx], sortColumn);
const bVal = getCellValue(data.value[bIdx], sortColumn);
if (sortDesc.value) {
return aVal > bVal ? -1 : aVal < bVal ? 1 : 0;
}
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
});
}
return indices;
});
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
const end = start + itemsPerPage.value;
// Only map indices to full objects for visible page
return filteredData.value.slice(start, end).map((idx) => data.value[idx]);
});
const totalPages = computed(() =>
Math.ceil(filteredData.value.length / itemsPerPage.value)
);
// Cache sort results
const sortCache = new Map();
const toggleSort = (column) => {
if (column.sortable) {
if (sortBy.value === column.key) {
sortDesc.value = !sortDesc.value;
} else {
sortBy.value = column.key;
sortDesc.value = false;
}
// Clear cache when sort changes
sortCache.clear();
}
};
const setItemsPerPage = (value) => {
itemsPerPage.value = parseInt(value, 10);
currentPage.value = 1;
};
// Debounced column filter
const setColumnFilter = debounce((column, value) => {
columnFilters.value[column] = value;
currentPage.value = 1;
}, 300);
return {
data,
columns,
sortBy,
sortDesc,
searchQuery,
currentPage,
itemsPerPage,
paginatedData,
totalPages,
toggleSort,
itemsPerPageOptions,
columnFilters,
setItemsPerPage,
setColumnFilter,
filteredData,
getCellValue,
};
};
// Utility functions
function memoize(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) {
cache.set(key, fn(...args));
}
return cache.get(key);
};
}
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}

22
composables/theme.js Normal file
View File

@ -0,0 +1,22 @@
export const useTheme = () => {
const currentTheme = useState("theme", () => "default");
const setTheme = (theme) => {
// Remove existing theme classes
if (process.client) {
document.documentElement.classList.remove("theme-dark", "theme-purple");
// Add new theme class if not default
if (theme !== "default") {
document.documentElement.classList.add(`theme-${theme}`);
}
}
currentTheme.value = theme;
};
return {
currentTheme,
setTheme,
};
};

View File

@ -0,0 +1,60 @@
export const useLayoutConfig = () => {
const layout = useState("layout-config", () => ({
isRTL: false,
sidebarLayout: "vertical", // 'vertical' | 'horizontal'
isMinimized: false,
}));
// Initialize layout on client side
if (process.client) {
const savedLayout = localStorage.getItem("layout-config");
if (savedLayout) {
layout.value = JSON.parse(savedLayout);
// Apply RTL direction if saved
document.documentElement.dir = layout.value.isRTL ? "rtl" : "ltr";
}
}
const toggleDirection = () => {
layout.value.isRTL = !layout.value.isRTL;
document.documentElement.dir = layout.value.isRTL ? "rtl" : "ltr";
// Save to localStorage
if (process.client) {
localStorage.setItem("layout-config", JSON.stringify(layout.value));
}
};
const toggleSidebarLayout = () => {
layout.value.sidebarLayout =
layout.value.sidebarLayout === "vertical" ? "horizontal" : "vertical";
// Save to localStorage
if (process.client) {
localStorage.setItem("layout-config", JSON.stringify(layout.value));
}
};
const toggleMinimize = () => {
layout.value.isMinimized = !layout.value.isMinimized;
if (process.client) {
localStorage.setItem("layout-config", JSON.stringify(layout.value));
}
};
const setLayout = (config) => {
layout.value = { ...layout.value, ...config };
if (process.client) {
localStorage.setItem("layout-config", JSON.stringify(layout.value));
if ("isRTL" in config) {
document.documentElement.dir = config.isRTL ? "rtl" : "ltr";
}
}
};
return {
layout,
toggleDirection,
toggleSidebarLayout,
toggleMinimize,
setLayout,
};
};

View File

@ -0,0 +1,20 @@
import adminNavigation from '~/navigation/admin.json'
export const useNavigation = () => {
const getAdminNavigation = () => {
// Here you could add logic to fetch from an API or local storage
// to support menu editing functionality
return adminNavigation.menu
}
const saveAdminNavigation = async (navigation) => {
// This is where you would implement the logic to save
// the updated navigation, either to an API or local storage
console.log('Saving navigation:', navigation)
}
return {
getAdminNavigation,
saveAdminNavigation
}
}

78
composables/useToast.js Normal file
View File

@ -0,0 +1,78 @@
import { ref } from 'vue'
const TOAST_TIMEOUT = 5000
// Create separate toast lists for each position
export const toasts = {
'top-left': ref([]),
'top-right': ref([]),
'bottom-left': ref([]),
'bottom-right': ref([])
}
export function useToast() {
const add = ({
title,
description,
type = 'default',
duration = TOAST_TIMEOUT,
position = 'bottom-right',
action,
}) => {
// Validate position
if (!['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(position)) {
position = 'bottom-right' // fallback to default if invalid
}
const id = Math.random().toString(36).slice(2, 9)
const toast = {
id,
title,
description,
type,
action,
position
}
// Add to the correct position array
toasts[position].value.push(toast)
if (duration !== Infinity) {
setTimeout(() => {
remove(id, position)
}, duration)
}
return id
}
const remove = (id, position = null) => {
// If position is provided, only check that position's array
if (position && toasts[position]) {
const index = toasts[position].value.findIndex((toast) => toast.id === id)
if (index !== -1) {
toasts[position].value.splice(index, 1)
return
}
}
// If position not provided or toast not found in specified position, check all positions
Object.values(toasts).forEach(positionToasts => {
const index = positionToasts.value.findIndex((toast) => toast.id === id)
if (index !== -1) {
positionToasts.value.splice(index, 1)
}
})
}
const getToasts = (position = 'bottom-right') => {
return toasts[position]?.value || []
}
return {
add,
remove,
getToasts,
toasts
}
}

55
error.vue Normal file
View File

@ -0,0 +1,55 @@
<script setup>
defineProps({
error: {
type: Object,
required: true,
},
});
const handleError = () => {
clearError({ redirect: "/" });
};
</script>
<template>
<NuxtLayout>
<div
class="min-h-screen bg-background flex items-center justify-center p-4"
>
<div class="text-center space-y-6">
<!-- Error Icon -->
<div class="flex justify-center">
<div
class="w-24 h-24 bg-danger/10 rounded-full flex items-center justify-center"
>
<Icon
:name="error?.statusCode === 404 ? 'ph:warning' : 'ph:x-circle'"
class="w-12 h-12 text-danger"
/>
</div>
</div>
<!-- Error Message -->
<div class="space-y-2">
<h1 class="text-4xl font-bold">
{{
error?.statusCode === 404 ? "Page Not Found" : "An Error Occurred"
}}
</h1>
<p class="text-muted-foreground text-lg">
{{
error?.message ||
"Sorry, we couldn't find the page you're looking for."
}}
</p>
</div>
<!-- Action Buttons -->
<div class="flex justify-center gap-4">
<Button variant="outline" @click="handleError">Go Home</Button>
<Button @click="handleError">Try Again</Button>
</div>
</div>
</div>
</NuxtLayout>
</template>

69
formkit.config.js Normal file
View File

@ -0,0 +1,69 @@
// formkit.config.ts
import { generateClasses } from "@formkit/themes";
import defaultTheme from "./formkit.theme.js";
import customInputs from "./components/formkit";
import { genesisIcons } from "@formkit/icons";
import {
createAutoHeightTextareaPlugin,
createMultiStepPlugin,
} from "@formkit/addons";
import "@formkit/addons/css/multistep";
const legends = ["checkbox_multi", "radio_multi", "repeater", "transferlist"];
function addAsteriskPlugin(node) {
if (
["button", "submit", "hidden", "group", "list", "meta"].includes(
node.props.type
)
)
return;
node.on("created", () => {
const legendOrLabel = legends.includes(
`${node.props.type}${node.props.options ? "_multi" : ""}`
)
? "legend"
: "label";
if (node.props.definition.schemaMemoKey) {
node.props.definition.schemaMemoKey += `${
node.props.options ? "_multi" : ""
}_add_asterisk`;
}
const schemaFn = node.props.definition.schema;
node.props.definition.schema = (sectionsSchema = {}) => {
sectionsSchema[legendOrLabel] = {
children: [
"$label",
{
$el: "span",
if: "$state.required",
attrs: {
class: "$classes.asterisk",
},
children: ["*"],
},
],
};
return schemaFn(sectionsSchema);
};
});
}
export default {
plugins: [
addAsteriskPlugin,
createAutoHeightTextareaPlugin(),
createMultiStepPlugin(),
],
icons: {
...genesisIcons,
},
config: {
classes: generateClasses(defaultTheme),
},
inputs: customInputs,
};

374
formkit.theme.js Normal file
View File

@ -0,0 +1,374 @@
// Base classifications for common styles
const baseStyles = {
label:
"block text-sm font-medium text-foreground mb-2 disabled:opacity-50 disabled:cursor-not-allowed",
message: "text-sm text-danger mt-1",
asterisk: "text-danger",
inner:
"relative flex items-center gap-2 border border-border rounded-md bg-background text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:border-foreground focus-within:ring-1 focus-within:ring-foreground focus:outline-0 focus:outline-transparent focus:border-primary focus:ring-0 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 shadow rounded-[var(--radius)]",
};
// Input group classifications
const inputClassifications = {
// Text-like inputs base styling
text: {
...baseStyles,
outer: "relative",
input: `
flex w-full
border-none
focus:border-none
focus:ring-0
focus:ring-transparent
focus:outline-none
bg-background text-sm
text-foreground
disabled:cursor-not-allowed disabled:opacity-50
read-only:cursor-not-allowed read-only:opacity-50
`
.replace(/\s+/g, " ")
.trim(),
prefix: "absolute inset-y-0 left-0 flex items-center pl-3 text-foreground",
suffix:
"absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground pointer-events-none",
},
// Box-type inputs (checkbox/radio)
box: {
inner: "relative flex items-start",
fieldset: "border-0 p-0 m-0",
legend:
"text-sm font-medium text-foreground mb-4 disabled:opacity-50 disabled:cursor-not-allowed",
wrapper:
"flex items-center h-4 mb-2 disabled:opacity-50 disabled:cursor-not-allowed",
help: "text-sm text-muted-foreground",
label:
"ml-2 text-sm font-normal text-foreground cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed",
message: "text-sm text-danger mt-1",
},
// Special inputs
button: {
wrapper: "relative",
input: `
inline-flex items-center justify-center
rounded-[var(--radius)] text-sm font-medium
ring-offset-background transition-colors
focus-visible:outline-none focus-visible:ring-1
focus-visible:ring-ring focus-visible:ring-offset-0
disabled:pointer-events-none disabled:opacity-50
bg-primary text-primary-foreground
hover:bg-primary/90 px-4 py-2
`
.replace(/\s+/g, " ")
.trim(),
},
otp: {
...baseStyles,
inner: "flex gap-2",
digit: `
flex w-10
rounded-[var(--radius)]
border border-border
bg-background text-center text-sm
ring-offset-background
focus:outline-0 focus:outline-transparent
focus:border-primary
focus:ring-0 focus:ring-ring
disabled:cursor-not-allowed disabled:opacity-50
`
.replace(/\s+/g, " ")
.trim(),
},
color: {
label:
"block mb-1 font-bold text-sm disabled:opacity-50 disabled:cursor-not-allowed",
input:
"w-16 cursor-pointer rounded-[var(--radius)] mb-2 border-none appearance-none bg-transparent",
},
file: {
...baseStyles,
inner: "flex flex-col",
input: `
flex h-9 w-full
rounded-[var(--radius)]
border border-border
bg-background text-sm
ring-offset-background
file:h-full
file:mr-4
file:px-4
file:border-0
file:bg-transparent
file:text-sm
file:font-medium
file:!bg-primary
file:!text-primary-foreground
file:hover:!bg-primary/90
file:transition-colors
file:cursor-pointer
cursor-pointer
focus-visible:outline-none focus-visible:ring-1
focus:ring-ring focus-visible:ring-offset-0
disabled:cursor-not-allowed disabled:opacity-50
shadow
`
.replace(/\s+/g, " ")
.trim(),
fileList: "flex flex-col gap-1 mt-1",
fileItem: "p-1 border-b border-border rounded-md",
fileRemove: "flex text-danger mt-1",
noFiles: "text-muted-foreground text-sm",
},
range: {
input:
"appearance-none w-full h-2 p-0 rounded-[var(--radius)] focus:outline-none focus:ring-0 focus:shadow-none bg-primary text-primary-foreground",
},
dropzone: {
...baseStyles,
dropzone: `
w-full
rounded-lg
border
border-dashed
border-border
bg-background
text-center
hover:cursor-pointer
hover:border-primary
hover:bg-background/50
focus-visible:outline-none
focus-visible:ring-2
focus-visible:ring-ring
focus-visible:ring-offset-2
disabled:cursor-not-allowed
disabled:opacity-50
transition-colors
duration-200
`
.replace(/\s+/g, " ")
.trim(),
preview: `
mt-4
space-y-2
`
.replace(/\s+/g, " ")
.trim(),
fileItem: `
flex
items-center
justify-between
p-2
rounded-md
bg-muted
text-sm
`
.replace(/\s+/g, " ")
.trim(),
fileInfo: `
flex
items-center
space-x-2
text-muted-foreground
`
.replace(/\s+/g, " ")
.trim(),
removeButton: `
p-1
hover:bg-background
rounded-full
transition-colors
`
.replace(/\s+/g, " ")
.trim(),
},
switch: {
wrapper: "flex flex-col gap-2",
help: "text-sm text-muted-foreground",
inner: "flex items-center gap-2",
input: `
relative inline-flex h-[20px] w-[36px] flex-shrink-0 cursor-pointer
rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out
focus:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2
focus-visible:ring-offset-background
disabled:cursor-not-allowed disabled:opacity-50
`
.replace(/\s+/g, " ")
.trim(),
label:
"text-sm font-medium text-foreground disabled:opacity-50 disabled:cursor-not-allowed",
message: "text-sm text-danger mt-1",
},
// Add specific styles for date-like inputs
date: {
...baseStyles,
outer: "relative",
input: `
w-full rounded-md
border border-border
bg-background px-3 py-2 text-sm
text-foreground
ring-offset-background
placeholder:text-muted-foreground
focus:outline-0 focus:outline-transparent
focus:border-primary
focus:ring-0 focus:ring-ring
disabled:cursor-not-allowed disabled:opacity-50 shadow
rounded-[var(--radius)]
`
.replace(/\s+/g, " ")
.trim(),
suffix:
"absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground pointer-events-none",
},
combobox: {
...baseStyles,
inner: "relative",
outer: "relative",
input: `
group flex min- w-full flex-wrap items-center gap-1.5
rounded-md border border-border bg-background px-2
text-sm ring-offset-background
focus-within:ring-1 focus-within:ring-ring focus-within:ring-offset-0 shadow
`
.replace(/\s+/g, " ")
.trim(),
inputWrapper: "flex flex-1 items-center gap-2 focus:border-none",
searchInput: `
flex-1 bg-transparent outline-none border-none
placeholder:text-muted-foreground
disabled:cursor-not-allowed focus:border-none focus:ring-0 focus:outline-none focus:ring-transparent
`
.replace(/\s+/g, " ")
.trim(),
caret: "h-4 w-4 shrink-0 opacity-50 transition-transform",
pill: `
inline-flex items-center gap-1 rounded-md
bg-secondary px-2 py-0.5 text-sm
text-secondary-foreground
`
.replace(/\s+/g, " ")
.trim(),
removeButton: `
p-0.5
hover:text-primary
focus:text-primary
focus:outline-none
`
.replace(/\s+/g, " ")
.trim(),
removeIcon: "h-3 w-3 text-secondary-foreground/70",
dropdown: `
absolute z-50 mt-2 max-h-[200px] w-full
overflow-auto rounded-md border
bg-popover text-popover-foreground shadow-md
`
.replace(/\s+/g, " ")
.trim(),
dropdownInner: "p-1",
option: `
relative flex w-full select-none items-center
rounded-sm px-2 py-1.5 text-sm outline-none
data-[highlighted]:bg-accent
data-[highlighted]:text-accent-foreground
hover:bg-accent hover:text-accent-foreground
`
.replace(/\s+/g, " ")
.trim(),
},
};
// Global styles that apply to all inputs
const globalStyles = {
label:
"block text-sm font-medium text-foreground disabled:opacity-50 disabled:cursor-not-allowed",
outer: "mb-4",
help: "text-sm text-muted-foreground mt-1",
messages: "list-none p-0 mt-1 mb-2",
message: "text-sm text-danger mt-1",
wrapper: "relative",
};
// Export theme configuration
export default {
global: globalStyles,
// Text-like inputs
text: inputClassifications.text,
email: inputClassifications.text,
password: inputClassifications.text,
url: inputClassifications.text,
tel: inputClassifications.text,
search: inputClassifications.text,
number: inputClassifications.text,
date: {
...inputClassifications.date,
suffix: `${inputClassifications.date.suffix} [&>svg]:h-4 [&>svg]:w-4`,
inner: "relative",
},
time: {
...inputClassifications.date,
suffix: `${inputClassifications.date.suffix} [&>svg]:h-4 [&>svg]:w-4`,
inner: "relative",
},
month: {
...inputClassifications.date,
suffix: `${inputClassifications.date.suffix} [&>svg]:h-4 [&>svg]:w-4`,
inner: "relative",
},
week: {
...inputClassifications.date,
suffix: `${inputClassifications.date.suffix} [&>svg]:h-4 [&>svg]:w-4`,
inner: "relative",
},
"datetime-local": {
...inputClassifications.date,
suffix: `${inputClassifications.date.suffix} [&>svg]:h-4 [&>svg]:w-4`,
inner: "relative",
},
mask: inputClassifications.text,
// Box-type inputs
checkbox: {
...inputClassifications.box,
input:
"h-4 w-4 rounded-[calc(var(--radius)-2px)] border-border bg-background cursor-pointer checked:bg-primary focus:ring-1 focus:ring-offset-0 focus:ring-primary focus:checked:bg-primary hover:bg-transparent hover:checked:bg-primary disabled:cursor-not-allowed disabled:opacity-50",
},
radio: {
...inputClassifications.box,
input:
"h-4 w-4 rounded-full border-border bg-background cursor-pointer checked:bg-primary focus:ring-1 focus:ring-offset-0 focus:ring-primary focus:checked:bg-primary hover:bg-transparent hover:checked:bg-primary disabled:cursor-not-allowed disabled:opacity-50",
},
// Special inputs
button: inputClassifications.button,
submit: inputClassifications.button,
color: inputClassifications.color,
file: inputClassifications.file,
range: inputClassifications.range,
otp: inputClassifications.otp,
// Complex inputs
select: { ...inputClassifications.text, option: "p-2" },
textarea: {
...inputClassifications.text,
},
dropzone: {
...inputClassifications.dropzone,
inner: "formkit-inner-dropzone",
dropzone: "formkit-dropzone",
},
switch: inputClassifications.switch,
combobox: inputClassifications.combobox,
};

107
layouts/admin.vue Normal file
View File

@ -0,0 +1,107 @@
<script setup>
const isOpen = ref(false);
const layoutStore = useLayoutStore();
// Initialize layout on mount
onMounted(() => {
layoutStore.initLayout();
});
const toggleSidebar = () => {
if (window.innerWidth < 768) {
isOpen.value = !isOpen.value;
} else {
layoutStore.toggleMinimize();
}
};
const isHorizontal = computed(() => layoutStore.sidebarLayout === "horizontal");
const isRTL = computed(() => layoutStore.isRTL);
const isMinimized = computed(() => layoutStore.isMinimized);
// Dynamic classes for the main content
const mainContentClasses = computed(() => {
if (isHorizontal.value) {
return "mt-14";
}
const sidebarWidth = isMinimized.value ? "md:ml-14" : "md:ml-56";
return isRTL.value
? `md:mr-${isMinimized.value ? "16" : "56"}`
: sidebarWidth;
});
// Add overlay click handler
const handleOverlayClick = () => {
if (window.innerWidth < 768) {
isOpen.value = false;
}
};
</script>
<template>
<div
class="min-h-screen bg-background"
:class="{ rtl: isRTL }"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<!-- Overlay for mobile -->
<div
v-if="isOpen"
class="fixed inset-0 bg-background/70 z-40 md:hidden"
@click="handleOverlayClick"
></div>
<!-- Sidebar -->
<AdminSideMenu
:is-open="isOpen"
:is-minimized="isMinimized"
class="bg-sidebar"
@toggle="toggleSidebar"
/>
<!-- Main Content -->
<div
:class="mainContentClasses"
class="transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]"
>
<!-- Header -->
<AdminHeader
:is-minimized="isMinimized"
class="bg-header"
@toggle="toggleSidebar"
/>
<!-- Page Content -->
<main class="p-6 bg-content min-h-screen z-0">
<div class="max-w-[1920px] mx-auto">
<slot />
</div>
</main>
</div>
</div>
</template>
<style>
/* RTL specific styles */
.rtl .md\:mr-56 {
margin-right: 14rem;
}
.rtl .md\:mr-16 {
margin-right: 4rem;
}
.rtl .md\:ml-56 {
margin-left: 0;
}
.rtl .md\:ml-16 {
margin-left: 0;
}
/* Add transition for overlay */
.bg-foreground\/50 {
transition: opacity 0.2s ease-in-out;
}
</style>

6
layouts/default.vue Normal file
View File

@ -0,0 +1,6 @@
<script setup></script>
<template>
<div>
<slot />
</div>
</template>

180
navigation/admin.json Normal file
View File

@ -0,0 +1,180 @@
{
"menu": [
{
"type": "section",
"title": "Platform",
"items": [
{
"name": "Home",
"icon": "ph:house",
"path": "/admin"
}
]
},
{
"type": "section",
"title": "Components",
"items": [
{
"name": "Actions",
"icon": "ph:cursor-click",
"children": [{ "name": "Button", "path": "/admin/component/button" }]
},
{
"name": "Data Display",
"icon": "ph:squares-four",
"children": [
{ "name": "Accordion", "path": "/admin/component/accordion" },
{ "name": "Avatar", "path": "/admin/component/avatar" },
{ "name": "Badge", "path": "/admin/component/badge" },
{ "name": "Card", "path": "/admin/component/card" },
{ "name": "Data Table", "path": "/admin/component/data-table" },
{ "name": "Progress", "path": "/admin/component/progress" },
{ "name": "Skeleton", "path": "/admin/component/skeleton" },
{ "name": "Table", "path": "/admin/component/table" }
]
},
{
"name": "Feedback",
"icon": "ph:bell",
"children": [
{ "name": "Alert", "path": "/admin/component/alert" },
{ "name": "Toast", "path": "/admin/component/toast" }
]
},
{
"name": "Navigation",
"icon": "ph:navigation-arrow",
"children": [
{ "name": "Breadcrumb", "path": "/admin/component/breadcrumb" },
{ "name": "Stepper", "path": "/admin/component/stepper" },
{ "name": "Tabs", "path": "/admin/component/tabs" }
]
},
{
"name": "Overlay",
"icon": "ph:stack",
"children": [
{ "name": "Context Menu", "path": "/admin/component/context-menu" },
{ "name": "Dropdown", "path": "/admin/component/dropdown" },
{ "name": "Hover Card", "path": "/admin/component/hover-card" },
{ "name": "Modal", "path": "/admin/component/modal" },
{ "name": "Popover", "path": "/admin/component/popover" },
{ "name": "Tooltip", "path": "/admin/component/tooltip" }
]
},
{
"name": "Layout",
"icon": "ph:layout",
"children": [
{ "name": "Carousel", "path": "/admin/component/carousel" },
{ "name": "Scroll Area", "path": "/admin/component/scroll-area" }
]
},
{
"name": "Forms",
"icon": "ph:text-columns",
"children": [
{
"name": "Basic Inputs",
"children": [
{ "name": "Text Input", "path": "/admin/form/element/input" },
{ "name": "Textarea", "path": "/admin/form/element/textarea" },
{ "name": "Select", "path": "/admin/form/element/select" },
{ "name": "Radio", "path": "/admin/form/element/radio" },
{ "name": "Checkbox", "path": "/admin/form/element/checkbox" }
]
},
{
"name": "Advanced Inputs",
"children": [
{ "name": "OTP Input", "path": "/admin/form/element/otp" },
{
"name": "Input Mask",
"path": "/admin/form/element/input-mask"
},
{ "name": "Combobox", "path": "/admin/form/element/combobox" },
{ "name": "Switch", "path": "/admin/form/element/switch" }
]
},
{
"name": "Date & Time",
"children": [
{ "name": "Date Picker", "path": "/admin/form/element/date" },
{ "name": "Time Picker", "path": "/admin/form/element/time" }
]
},
{
"name": "Rich Inputs",
"children": [
{ "name": "Color Picker", "path": "/admin/form/element/color" },
{ "name": "File Upload", "path": "/admin/form/element/file" }
]
},
{
"name": "Form Layouts",
"children": [
{
"name": "Form Section",
"path": "/admin/form/layout/section"
},
{ "name": "Form Grid", "path": "/admin/form/layout/grid" },
{ "name": "Form Wizard", "path": "/admin/form/layout/wizard" }
]
}
]
}
]
},
{
"type": "section",
"title": "Setting",
"items": [
{
"name": "Settings",
"icon": "ph:gear",
"children": [
{
"name": "Profile",
"path": "/admin/settings/profile",
"icon": "ph:user"
},
{
"name": "Appearance",
"path": "/admin/settings/appearance",
"icon": "ph:paint-brush"
}
]
}
]
},
{
"type": "section",
"title": "Examples",
"items": [
{
"name": "Management",
"icon": "ph:folder",
"children": [
{
"name": "Users",
"children": [
{ "name": "List", "path": "#" },
{ "name": "Create", "path": "#" },
{ "name": "Groups", "path": "#" }
]
},
{
"name": "Products",
"children": [
{ "name": "Catalog", "path": "#" },
{ "name": "Categories", "path": "#" },
{ "name": "Inventory", "path": "#" }
]
}
]
}
]
}
]
}

42
nuxt.config.ts Normal file
View File

@ -0,0 +1,42 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2024-04-03",
devtools: { enabled: false },
ssr: false,
modules: [
"@nuxtjs/tailwindcss",
"@nuxt/icon",
"@nuxt/fonts",
"@formkit/nuxt",
"@pinia/nuxt",
"@pinia-plugin-persistedstate/nuxt",
],
tailwindcss: {
cssPath: ["~/assets/css/tailwind.css", { injectPosition: 0 }],
viewer: false,
},
fonts: {
priority: ["google"],
provider: "google",
},
app: {
head: {
script: [
{
innerHTML: `(function() {
try {
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default');
if (theme !== 'default') {
document.documentElement.classList.add('theme-' + theme);
}
document.documentElement.style.colorScheme = theme;
} catch (e) {}
})()`,
type: "text/javascript",
},
],
},
},
});

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@floating-ui/vue": "^1.1.5",
"@formkit/addons": "^1.6.9",
"@formkit/icons": "^1.6.9",
"@formkit/nuxt": "^1.6.7",
"@nuxt/fonts": "^0.10.2",
"@nuxt/icon": "^1.7.2",
"@splidejs/vue-splide": "^0.6.12",
"@tailwindcss/forms": "^0.5.9",
"@tiptap/extension-link": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@tiptap/vue-3": "^2.11.5",
"@vueuse/core": "^11.3.0",
"meilisearch": "^0.48.2",
"nuxt": "^3.15.4",
"prismjs": "^1.29.0",
"vue": "latest",
"vue-router": "latest"
},
"packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1",
"devDependencies": {
"@iconify-json/ph": "^1.2.1",
"@nuxtjs/tailwindcss": "^6.12.2",
"@pinia-plugin-persistedstate/nuxt": "^1.2.1",
"@pinia/nuxt": "^0.5.0",
"sass": "^1.81.0"
}
}

View File

@ -0,0 +1,304 @@
<script setup>
definePageMeta({
title: "Accordion",
layout: "admin",
breadcrumb: [
{
name: "Component",
path: "/admin/component",
},
{
name: "Accordion",
type: "current",
},
],
});
const basicCode = `<Accordion type="single" defaultValue="item-1">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that match your theme.
</AccordionContent>
</AccordionItem>
</Accordion>`;
const multipleCode = `<Accordion type="multiple" :defaultValue="['item-1', 'item-2']">
<AccordionItem value="item-1">
<AccordionTrigger>Can I open multiple items?</AccordionTrigger>
<AccordionContent>
Yes. Just set the type prop to "multiple".
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Can I customize the styles?</AccordionTrigger>
<AccordionContent>
Yes. The component accepts custom classes and styles.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. The content slides smoothly with a transition effect.
</AccordionContent>
</AccordionItem>
</Accordion>`;
const customCode = `<Accordion type="single" defaultValue="item-1" class="w-full max-w-md">
<AccordionItem value="item-1">
<AccordionTrigger class="hover:bg-muted/50 px-4">
<div class="flex items-center gap-2">
<Icon name="ph:gear" class="h-5 w-5" />
<span>Settings</span>
</div>
</AccordionTrigger>
<AccordionContent class="px-4">
Manage your account settings and preferences.
</AccordionContent>
</AccordionItem>
</Accordion>`;
const faqCode = `<Accordion type="single" class="w-full max-w-3xl">
<AccordionItem value="item-1">
<AccordionTrigger>What payment methods do you accept?</AccordionTrigger>
<AccordionContent>
We accept all major credit cards, PayPal, and bank transfers.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>How long does shipping take?</AccordionTrigger>
<AccordionContent>
Shipping typically takes 3-5 business days for domestic orders.
</AccordionContent>
</AccordionItem>
</Accordion>`;
</script>
<template>
<div class="space-y-6">
<!-- Introduction -->
<div class="mb-6">
<h1 class="text-2xl font-semibold">Accordion</h1>
<p class="text-gray-600">A vertically stacked set of interactive headings that each reveal a section of content. Built with accessibility and customization in mind.</p>
</div>
<!-- Basic Usage -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Basic Usage</h2>
</CardTitle>
<CardDescription>
A simple accordion with single item expansion.
</CardDescription>
</CardHeader>
<CardContent>
<div class="mb-6">
<Accordion type="single" defaultValue="item-1">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that match your theme.
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="basicCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Multiple -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Multiple Items</h2>
</CardTitle>
<CardDescription>
Allow multiple items to be expanded simultaneously.
</CardDescription>
</CardHeader>
<CardContent>
<div class="mb-6">
<Accordion type="multiple" :defaultValue="['item-1', 'item-2']">
<AccordionItem value="item-1">
<AccordionTrigger>Can I open multiple items?</AccordionTrigger>
<AccordionContent>
Yes. Just set the type prop to "multiple".
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Can I customize the styles?</AccordionTrigger>
<AccordionContent>
Yes. The component accepts custom classes and styles.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. The content slides smoothly with a transition effect.
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="multipleCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Custom Styling -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Custom Styling</h2>
</CardTitle>
<CardDescription>
Customize the accordion with icons and additional styling.
</CardDescription>
</CardHeader>
<CardContent>
<div class="mb-6">
<Accordion type="single" defaultValue="item-1" class="w-full max-w-md">
<AccordionItem value="item-1">
<AccordionTrigger class="hover:bg-muted/50 px-4">
<div class="flex items-center gap-2">
<Icon name="ph:gear" class="h-5 w-5" />
<span>Settings</span>
</div>
</AccordionTrigger>
<AccordionContent class="px-4">
Manage your account settings and preferences.
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="customCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- FAQ Example -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">FAQ Example</h2>
</CardTitle>
<CardDescription>
Using the accordion for a FAQ section.
</CardDescription>
</CardHeader>
<CardContent>
<div class="mb-6">
<Accordion type="single" class="w-full max-w-3xl">
<AccordionItem value="item-1">
<AccordionTrigger>What payment methods do you accept?</AccordionTrigger>
<AccordionContent>
We accept all major credit cards, PayPal, and bank transfers.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>How long does shipping take?</AccordionTrigger>
<AccordionContent>
Shipping typically takes 3-5 business days for domestic orders.
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="faqCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Props Documentation -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Props & Components</h2>
</CardTitle>
<CardDescription>
Available props and subcomponents for building accordions.
</CardDescription>
</CardHeader>
<CardContent>
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs uppercase bg-muted/50">
<tr>
<th class="px-6 py-3">Component</th>
<th class="px-6 py-3">Prop</th>
<th class="px-6 py-3">Type</th>
<th class="px-6 py-3">Default</th>
<th class="px-6 py-3">Description</th>
</tr>
</thead>
<tbody>
<tr class="border-b">
<td class="px-6 py-4 font-medium">Accordion</td>
<td class="px-6 py-4">type</td>
<td class="px-6 py-4">string</td>
<td class="px-6 py-4">'single'</td>
<td class="px-6 py-4">
Type of accordion ('single' or 'multiple')
</td>
</tr>
<tr class="border-b">
<td class="px-6 py-4 font-medium">Accordion</td>
<td class="px-6 py-4">defaultValue</td>
<td class="px-6 py-4">string | array</td>
<td class="px-6 py-4">''</td>
<td class="px-6 py-4">
Default expanded item(s)
</td>
</tr>
<tr class="border-b">
<td class="px-6 py-4 font-medium">AccordionItem</td>
<td class="px-6 py-4">value</td>
<td class="px-6 py-4">string</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">Unique identifier for the item</td>
</tr>
<tr class="border-b">
<td class="px-6 py-4 font-medium">AccordionTrigger</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">Clickable trigger element</td>
</tr>
<tr class="border-b">
<td class="px-6 py-4 font-medium">AccordionContent</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">Expandable content section</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</template>

View File

@ -0,0 +1,252 @@
<script setup>
definePageMeta({
title: "Alert",
layout: "admin",
breadcrumb: [
{
name: "Component",
path: "/admin/component",
},
{
name: "Alert",
type: "current",
},
],
});
const basicCode = `<Alert>
<AlertTitle>Default Alert</AlertTitle>
<AlertDescription>
This is a default alert check it out!
</AlertDescription>
</Alert>`;
const variantsCode = `<Alert variant="default">
<AlertTitle>Default Alert</AlertTitle>
<AlertDescription>This is a default alert.</AlertDescription>
</Alert>
<Alert variant="info">
<AlertTitle>Info Alert</AlertTitle>
<AlertDescription>This is an info alert.</AlertDescription>
</Alert>
<Alert variant="success">
<AlertTitle>Success Alert</AlertTitle>
<AlertDescription>This is a success alert.</AlertDescription>
</Alert>
<Alert variant="warning">
<AlertTitle>Warning Alert</AlertTitle>
<AlertDescription>This is a warning alert.</AlertDescription>
</Alert>
<Alert variant="danger">
<AlertTitle>Danger Alert</AlertTitle>
<AlertDescription>This is a danger alert.</AlertDescription>
</Alert>`;
const simpleCode = `<Alert variant="info">
<AlertTitle>Browser Update Available</AlertTitle>
<AlertDescription>
A new version of your browser is available. Please update for the best experience.
</AlertDescription>
</Alert>`;
const customCode = `<Alert variant="success" class="max-w-md">
<AlertTitle class="flex items-center gap-2">
<Icon name="ph:check-circle" class="h-5 w-5" />
Payment Successful
</AlertTitle>
<AlertDescription>
Your payment has been successfully processed. We've sent you a confirmation email.
</AlertDescription>
</Alert>`;
</script>
<template>
<div class="space-y-6">
<!-- Introduction -->
<div class="mb-6">
<h1 class="text-2xl font-semibold">Alert</h1>
<p class="text-gray-600">Displays important messages and feedback to users with various styles and states. Built with accessibility in mind.</p>
</div>
<!-- Basic Usage -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Basic Usage</h2>
</CardTitle>
<CardDescription>
A simple alert with title and description.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-4 mb-6">
<Alert>
<AlertTitle>Default Alert</AlertTitle>
<AlertDescription>
This is a default alert check it out!
</AlertDescription>
</Alert>
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="basicCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Variants -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Variants</h2>
</CardTitle>
<CardDescription>
Different alert styles to convey various types of messages.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-4 mb-6">
<Alert variant="default">
<AlertTitle>Default Alert</AlertTitle>
<AlertDescription>This is a default alert.</AlertDescription>
</Alert>
<Alert variant="info">
<AlertTitle>Info Alert</AlertTitle>
<AlertDescription>This is an info alert.</AlertDescription>
</Alert>
<Alert variant="success">
<AlertTitle>Success Alert</AlertTitle>
<AlertDescription>This is a success alert.</AlertDescription>
</Alert>
<Alert variant="warning">
<AlertTitle>Warning Alert</AlertTitle>
<AlertDescription>This is a warning alert.</AlertDescription>
</Alert>
<Alert variant="danger">
<AlertTitle>Danger Alert</AlertTitle>
<AlertDescription>This is a danger alert.</AlertDescription>
</Alert>
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="variantsCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Simple Example -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Simple Example</h2>
</CardTitle>
<CardDescription>
A practical example of using the Alert component.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-4 mb-6">
<Alert variant="info">
<AlertTitle>Browser Update Available</AlertTitle>
<AlertDescription>
A new version of your browser is available. Please update for the best experience.
</AlertDescription>
</Alert>
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="simpleCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Custom Example -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Custom Example</h2>
</CardTitle>
<CardDescription>
Customizing the Alert component with icons and additional styling.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-4 mb-6">
<Alert variant="success" class="max-w-md">
<AlertTitle class="flex items-center gap-2">
<Icon name="ph:check-circle" class="h-5 w-5" />
Payment Successful
</AlertTitle>
<AlertDescription>
Your payment has been successfully processed. We've sent you a confirmation email.
</AlertDescription>
</Alert>
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="customCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Props Documentation -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Props & Components</h2>
</CardTitle>
<CardDescription>
Available props and subcomponents for building alerts.
</CardDescription>
</CardHeader>
<CardContent>
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs uppercase bg-muted/50">
<tr>
<th class="px-6 py-3">Component</th>
<th class="px-6 py-3">Prop</th>
<th class="px-6 py-3">Type</th>
<th class="px-6 py-3">Default</th>
<th class="px-6 py-3">Description</th>
</tr>
</thead>
<tbody>
<tr class="border-b">
<td class="px-6 py-4 font-medium">Alert</td>
<td class="px-6 py-4">variant</td>
<td class="px-6 py-4">string</td>
<td class="px-6 py-4">'default'</td>
<td class="px-6 py-4">
Alert style variant (default, info, success, warning, danger)
</td>
</tr>
<tr class="border-b">
<td class="px-6 py-4 font-medium">AlertTitle</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">Title component for the alert</td>
</tr>
<tr class="border-b">
<td class="px-6 py-4 font-medium">AlertDescription</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">-</td>
<td class="px-6 py-4">Description component for the alert</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</template>

View File

@ -0,0 +1,217 @@
<script setup>
definePageMeta({
title: "Avatar",
layout: "admin",
});
const basicCode = `<Avatar src="/path/to/image.jpg" alt="User Name" />
<Avatar alt="John Doe" fallback="JD" />
<Avatar alt="No Image" />`;
const sizesCode = `<Avatar src="/path/to/image.jpg" alt="User Name" size="sm" />
<Avatar src="/path/to/image.jpg" alt="User Name" size="default" />
<Avatar src="/path/to/image.jpg" alt="User Name" size="lg" />`;
const fallbackCode = `<Avatar alt="John Doe" fallback="JD" />
<Avatar alt="Alice Smith" /> <!-- Will show "AS" -->
<Avatar alt="No Image" /> <!-- Will show "NA" -->`;
const groupCode = `<AvatarGroup :max="3">
<Avatar src="/user1.jpg" alt="User 1" />
<Avatar src="/user2.jpg" alt="User 2" />
<Avatar src="/user3.jpg" alt="User 3" />
<Avatar src="/user4.jpg" alt="User 4" />
<Avatar src="/user5.jpg" alt="User 5" />
</AvatarGroup>`;
</script>
<template>
<div class="space-y-6">
<!-- Introduction -->
<div class="mb-6">
<h1 class="text-2xl font-semibold">Avatar</h1>
<p class="text-gray-600">A versatile avatar component for displaying user images with fallback support and group functionality.</p>
</div>
<!-- Basic Usage -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Basic Usage</h2>
</CardTitle>
<CardDescription>
Basic avatar examples showcasing image, fallback, and initial states.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-wrap gap-4 mb-6">
<Avatar src="/path/to/image.jpg" alt="User Name" />
<Avatar alt="John Doe" fallback="JD" />
<Avatar alt="No Image" />
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="basicCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Sizes -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Sizes</h2>
</CardTitle>
<CardDescription>
Different avatar sizes for various use cases.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-wrap items-center gap-4 mb-6">
<Avatar src="/path/to/image.jpg" alt="User Name" size="sm" />
<Avatar src="/path/to/image.jpg" alt="User Name" size="default" />
<Avatar src="/path/to/image.jpg" alt="User Name" size="lg" />
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="sizesCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Fallback -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Fallback Behavior</h2>
</CardTitle>
<CardDescription>
Avatar fallback states when images are unavailable.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-wrap items-center gap-4 mb-6">
<Avatar alt="John Doe" fallback="JD" />
<Avatar alt="Alice Smith" />
<Avatar alt="No Image" />
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="fallbackCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Avatar Group -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Avatar Group</h2>
</CardTitle>
<CardDescription>
Group multiple avatars with overflow count.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-wrap items-center gap-4 mb-6">
<AvatarGroup :max="3">
<Avatar src="/path/to/image1.jpg" alt="User 1" />
<Avatar src="/path/to/image2.jpg" alt="User 2" />
<Avatar src="/path/to/image3.jpg" alt="User 3" />
<Avatar src="/path/to/image4.jpg" alt="User 4" />
<Avatar src="/path/to/image5.jpg" alt="User 5" />
</AvatarGroup>
</div>
<div class="mt-4">
<ClientOnly>
<CodeBlock :code="groupCode" language="markup" />
</ClientOnly>
</div>
</CardContent>
</Card>
<!-- Props Documentation -->
<Card>
<CardHeader>
<CardTitle>
<h2 class="text-xl font-semibold">Props</h2>
</CardTitle>
<CardDescription>
Available props for customizing the Avatar components.
</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold mb-3">Avatar Props</h3>
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs uppercase bg-muted/50">
<tr>
<th class="px-6 py-3">Prop</th>
<th class="px-6 py-3">Type</th>
<th class="px-6 py-3">Default</th>
<th class="px-6 py-3">Description</th>
</tr>
</thead>
<tbody>
<tr class="border-b">
<td class="px-6 py-4 font-medium">src</td>
<td class="px-6 py-4">string</td>
<td class="px-6 py-4">''</td>
<td class="px-6 py-4">URL of the avatar image</td>
</tr>
<tr class="border-b">
<td class="px-6 py-4 font-medium">alt</td>
<td class="px-6 py-4">string</td>
<td class="px-6 py-4">required</td>
<td class="px-6 py-4">Alternative text and used for generating initials</td>
</tr>
<tr class="border-b">
<td class="px-6 py-4 font-medium">fallback</td>
<td class="px-6 py-4">string</td>
<td class="px-6 py-4">''</td>
<td class="px-6 py-4">Custom fallback text when image fails to load</td>
</tr>
<tr class="border-b">
<td class="px-6 py-4 font-medium">size</td>
<td class="px-6 py-4">string</td>
<td class="px-6 py-4">'default'</td>
<td class="px-6 py-4">Size of the avatar (sm, default, lg)</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-3">Avatar Group Props</h3>
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs uppercase bg-muted/50">
<tr>
<th class="px-6 py-3">Prop</th>
<th class="px-6 py-3">Type</th>
<th class="px-6 py-3">Default</th>
<th class="px-6 py-3">Description</th>
</tr>
</thead>
<tbody>
<tr class="border-b">
<td class="px-6 py-4 font-medium">max</td>
<td class="px-6 py-4">number</td>
<td class="px-6 py-4">4</td>
<td class="px-6 py-4">Maximum number of avatars to display before showing count</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More