Replace PrimeVue with shadcn #5

Closed
liviu wants to merge 5 commits from GF-1-shadcn into production
38 changed files with 1117 additions and 19 deletions
Showing only changes of commit 671baaf079 - Show all commits

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { AvatarRoot } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<AvatarRoot data-slot="avatar" :class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)">
<slot />
</AvatarRoot>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { AvatarFallback } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AvatarImageProps } from "reka-ui";
import { AvatarImage } from "reka-ui";
const props = defineProps<AvatarImageProps>();
</script>
<template>
<AvatarImage data-slot="avatar-image" v-bind="props" class="aspect-square size-full">
<slot />
</AvatarImage>
</template>

View file

@ -0,0 +1,3 @@
export { default as Avatar } from "./Avatar.vue";
export { default as AvatarFallback } from "./AvatarFallback.vue";
export { default as AvatarImage } from "./AvatarImage.vue";

View file

@ -0,0 +1,13 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<nav aria-label="breadcrumb" data-slot="breadcrumb" :class="props.class">
<slot />
</nav>
</template>

View file

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue";
import { MoreHorizontal } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>

View file

@ -0,0 +1,14 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<li data-slot="breadcrumb-item" :class="cn('inline-flex items-center gap-1.5', props.class)">
<slot />
</li>
</template>

View file

@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>(), {
as: "a",
});
</script>
<template>
<Primitive
data-slot="breadcrumb-link"
:as="as"
:as-child="asChild"
:class="cn('hover:text-foreground transition-colors', props.class)"
>
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<ol
data-slot="breadcrumb-list"
:class="cn('text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>

View file

@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('text-foreground font-normal', props.class)"
>
<slot />
</span>
</template>

View file

@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue";
import { ChevronRight } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<li data-slot="breadcrumb-separator" role="presentation" aria-hidden="true" :class="cn('[&>svg]:size-3.5', props.class)">
<slot>
<ChevronRight />
</slot>
</li>
</template>

View file

@ -0,0 +1,7 @@
export { default as Breadcrumb } from "./Breadcrumb.vue";
export { default as BreadcrumbEllipsis } from "./BreadcrumbEllipsis.vue";
export { default as BreadcrumbItem } from "./BreadcrumbItem.vue";
export { default as BreadcrumbLink } from "./BreadcrumbLink.vue";
export { default as BreadcrumbList } from "./BreadcrumbList.vue";
export { default as BreadcrumbPage } from "./BreadcrumbPage.vue";
export { default as BreadcrumbSeparator } from "./BreadcrumbSeparator.vue";

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CollapsibleRootEmits, CollapsibleRootProps } from "reka-ui";
import { CollapsibleRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps<CollapsibleRootProps>();
const emits = defineEmits<CollapsibleRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<CollapsibleRoot v-slot="slotProps" data-slot="collapsible" v-bind="forwarded">
<slot v-bind="slotProps" />
</CollapsibleRoot>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { CollapsibleContentProps } from "reka-ui";
import { CollapsibleContent } from "reka-ui";
const props = defineProps<CollapsibleContentProps>();
</script>
<template>
<CollapsibleContent data-slot="collapsible-content" v-bind="props">
<slot />
</CollapsibleContent>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { CollapsibleTriggerProps } from "reka-ui";
import { CollapsibleTrigger } from "reka-ui";
const props = defineProps<CollapsibleTriggerProps>();
</script>
<template>
<CollapsibleTrigger data-slot="collapsible-trigger" v-bind="props">
<slot />
</CollapsibleTrigger>
</template>

View file

@ -0,0 +1,3 @@
export { default as Collapsible } from "./Collapsible.vue";
export { default as CollapsibleContent } from "./CollapsibleContent.vue";
export { default as CollapsibleTrigger } from "./CollapsibleTrigger.vue";

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui";
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps<DropdownMenuRootProps>();
const emits = defineEmits<DropdownMenuRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuRoot v-slot="slotProps" data-slot="dropdown-menu" v-bind="forwarded">
<slot v-bind="slotProps" />
</DropdownMenuRoot>
</template>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import { DropdownMenuCheckboxItem, DropdownMenuItemIndicator, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class
)
"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuContent, DropdownMenuPortal, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(), {
sideOffset: 4,
});
const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
props.class
)
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DropdownMenuGroupProps } from "reka-ui";
import { DropdownMenuGroup } from "reka-ui";
const props = defineProps<DropdownMenuGroupProps>();
</script>
<template>
<DropdownMenuGroup data-slot="dropdown-menu-group" v-bind="props">
<slot />
</DropdownMenuGroup>
</template>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = withDefaults(
defineProps<
DropdownMenuItemProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
variant?: "default" | "destructive";
}
>(),
{
variant: "default",
}
);
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class
)
"
>
<slot />
</DropdownMenuItem>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuLabelProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuLabel, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"]; inset?: boolean }>();
const delegatedProps = reactiveOmit(props, "class", "inset");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuLabel
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui";
import { DropdownMenuRadioGroup, useForwardPropsEmits } from "reka-ui";
const props = defineProps<DropdownMenuRadioGroupProps>();
const emits = defineEmits<DropdownMenuRadioGroupEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuRadioGroup data-slot="dropdown-menu-radio-group" v-bind="forwarded">
<slot />
</DropdownMenuRadioGroup>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Circle } from "lucide-vue-next";
import { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>();
const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class
)
"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Circle class="size-2 fill-current" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuSeparatorProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuSeparator } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuSeparatorProps & {
class?: HTMLAttributes["class"];
}
>();
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DropdownMenuSeparator
data-slot="dropdown-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<span data-slot="dropdown-menu-shortcut" :class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)">
<slot />
</span>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui";
import { DropdownMenuSub, useForwardPropsEmits } from "reka-ui";
const props = defineProps<DropdownMenuSubProps>();
const emits = defineEmits<DropdownMenuSubEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
<slot v-bind="slotProps" />
</DropdownMenuSub>
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
props.class
)
"
>
<slot />
</DropdownMenuSubContent>
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { ChevronRight } from "lucide-vue-next";
import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"]; inset?: boolean }>();
const delegatedProps = reactiveOmit(props, "class", "inset");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
props.class
)
"
>
<slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { DropdownMenuTriggerProps } from "reka-ui";
import { DropdownMenuTrigger, useForwardProps } from "reka-ui";
const props = defineProps<DropdownMenuTriggerProps>();
const forwardedProps = useForwardProps(props);
</script>
<template>
<DropdownMenuTrigger data-slot="dropdown-menu-trigger" v-bind="forwardedProps">
<slot />
</DropdownMenuTrigger>
</template>

View file

@ -0,0 +1,16 @@
export { default as DropdownMenu } from "./DropdownMenu.vue";
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue";
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue";
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue";
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue";
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue";
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue";
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue";
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue";
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue";
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue";
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue";
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue";
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue";
export { DropdownMenuPortal } from "reka-ui";

View file

@ -1,14 +1,48 @@
<script setup lang="ts">
import DefaultSidebar from "~/layouts/default/Sidebar.vue";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "~/components/ui/breadcrumb";
import { Separator } from "~/components/ui/separator";
const currentYear = new Date().getFullYear();
</script>
<template>
<SidebarProvider>
<DefaultSidebar />
<main>
<SidebarTrigger />
<SidebarInset>
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem class="hidden md:block">
<BreadcrumbLink href="#"> Building Your Application </BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator class="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<main class="flex flex-1 flex-col gap-4 p-4">
<slot />
</main>
<footer class="flex h-12 shrink-0 items-center gap-2 border-b px-4" data-testid="footer">
<div v-if="currentYear === 2025" class="bg-muted/50 flex-1 rounded-xl p-2 text-center">Glowing Fiesta 2025</div>
<div v-else class="bg-muted/50 flex-1 rounded-xl p-2 text-center">Glowing Fiesta 2025 - {{ currentYear }}</div>
</footer>
</SidebarInset>
</SidebarProvider>
</template>

View file

@ -1,20 +1,113 @@
<script setup lang="ts">
import { computed } from "vue";
import {
BadgeCheck,
Bell,
ChevronRight,
ChevronsUpDown,
CreditCard,
BookOpen,
HandCoins,
LogOut,
Settings2,
Sparkles,
SquareTerminal,
} from "lucide-vue-next";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
type SidebarProps,
} from "~/components/ui/sidebar";
import { HandCoins } from "lucide-vue-next";
const props = withDefaults(defineProps<SidebarProps>(), {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
const data = {
user: {
name: "Liviu",
email: "x.liviu@gmail.com",
avatar: "https://git.burcusel.nl/avatars/bcc51c59d08174c798ba7db59631df6cb820e042679af9098cc03ef68330f486?size=200",
},
navMain: [
{
title: "Playground",
url: "#",
icon: SquareTerminal,
isActive: true,
items: [
{ title: "History", url: "#" },
{ title: "Starred", url: "#" },
{ title: "Settings", url: "#" },
],
},
{
title: "Documentation",
url: "#",
icon: BookOpen,
isActive: true,
items: [
{ title: "Introduction", url: "#" },
{ title: "Get Started", url: "#" },
{ title: "Tutorials", url: "#" },
{ title: "Changelog", url: "#" },
],
},
{
title: "Settings",
url: "#",
icon: Settings2,
isActive: true,
items: [
{ title: "General", url: "#" },
{ title: "Billing", url: "#" },
{ title: "Limits", url: "#" },
],
},
],
};
interface NavItem {
title: string;
url: string;
icon?: any;
isActive?: boolean;
items?: { title: string; url: string }[];
}
interface SidebarLayoutProps extends /* @vue-ignore */ SidebarProps {
navItems?: NavItem[];
}
const props = withDefaults(defineProps<SidebarLayoutProps>(), {
collapsible: "icon",
});
const navMain = computed(() => props.navItems || data.navMain);
const { isMobile } = useSidebar();
</script>
<template>
@ -30,16 +123,123 @@ const props = withDefaults(defineProps<SidebarProps>(), {
<HandCoins class="size-4" />
</div>
<div class="flex flex-col gap-0.5 leading-none">
<span class="font-medium">Glowing Fiesta</span>
<span class="">v1.0.0</span>
<span class="font-bold text-primary">Glowing Fiesta</span>
<span>v1.0.0</span>
</div>
</NuxtLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent></SidebarContent>
<SidebarFooter></SidebarFooter>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<Collapsible
v-for="item in navMain"
:key="item.title"
as-child
:default-open="item.isActive"
class="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" class="text-primary" />
<span class="font-bold text-primary">{{ item.title }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 text-primary"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
<SidebarMenuSubButton as-child>
<NuxtLink :to="subItem.url">
<span>{{ subItem.title }}</span>
</NuxtLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="data.user.avatar" :alt="data.user.name" />
<AvatarFallback class="rounded-lg"> LB </AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ data.user.name }}</span>
<span class="truncate text-xs">{{ data.user.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
:side="isMobile ? 'bottom' : 'right'"
align="end"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="data.user.avatar" :alt="data.user.name" />
<AvatarFallback class="rounded-lg">LB</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ data.user.name }}</span>
<span class="truncate text-xs">{{ data.user.email }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail></SidebarRail>
</Sidebar>
</template>

View file

@ -1,10 +1,29 @@
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@/components/ui/button";
const lastClicked = ref<string>("None");
const buttonClicked = (variant: string) => {
lastClicked.value = variant;
};
</script>
<template>
<h1 class="text-3xl font-bold">Dashboard</h1>
<div class="container">
<Button size="lg">Testing 1, 2, 3</Button>
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="bg-muted/50 aspect-video rounded-xl" />
<div class="bg-muted/50 aspect-video rounded-xl flex items-center justify-center gap-2">
<span class="text-primary">Last clicked button:</span>
<span class="font-bold text-lime-500">{{ lastClicked }}</span>
</div>
<div class="bg-muted/50 aspect-video rounded-xl" />
</div>
<div class="bg-muted/50 min-h-screen flex-1 rounded-xl md:min-h-min flex items-center justify-center gap-2">
<Button variant="default" @click="buttonClicked('default')">Default</Button>
<Button variant="outline" @click="buttonClicked('outline')">Outline</Button>
<Button variant="ghost" @click="buttonClicked('ghost')">Ghost</Button>
<Button variant="link" @click="buttonClicked('link')">Link</Button>
<Button variant="secondary" @click="buttonClicked('secondary')">Secondary</Button>
<Button variant="destructive" @click="buttonClicked('destructive')">Destructive</Button>
</div>
</template>

View file

@ -1,11 +1,38 @@
import { describe, expect, it } from "vitest";
import { mount, type VueWrapper } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import DefaultLayout from "~/layouts/Default.vue";
describe("Default.vue", () => {
const wrapper: VueWrapper = mount(DefaultLayout);
afterEach(() => {
vi.useRealTimers();
});
it("loads without crashing", () => {
const wrapper = mount(DefaultLayout);
expect(wrapper.exists()).toBe(true);
});
describe("Footer", () => {
it("footer is displayed", () => {
const wrapper = mount(DefaultLayout);
const footer = wrapper.find("[data-testid='footer']");
expect(footer.exists()).toBe(true);
});
it("footer shows only 2025 when current year is 2025", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2025, 0, 1));
const wrapper = mount(DefaultLayout);
const footer = wrapper.find("[data-testid='footer']");
expect(footer.text()).toBe("Glowing Fiesta 2025");
});
it("footer shows range when current year is not 2025", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2069, 0, 1));
const wrapper = mount(DefaultLayout);
const footer = wrapper.find("[data-testid='footer']");
expect(footer.text()).toBe("Glowing Fiesta 2025 - 2069");
});
});
});

View file

@ -0,0 +1,191 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi } from "vitest";
import SidebarLayout from "~/layouts/default/Sidebar.vue";
import { ref } from "vue";
import type * as SidebarUI from "~/components/ui/sidebar";
const { useSidebarMock } = vi.hoisted(() => ({
useSidebarMock: vi.fn(),
}));
// Mock the UI components and hook
vi.mock("~/components/ui/sidebar", async (importOriginal) => {
const actual = await importOriginal<typeof SidebarUI>();
const { ref } = await import("vue");
const MockComponent = {
template: "<div><slot /></div>",
inheritAttrs: false,
};
// Default implementation
useSidebarMock.mockReturnValue({
isMobile: ref(false),
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
return {
...actual,
Sidebar: MockComponent,
SidebarContent: MockComponent,
SidebarFooter: MockComponent,
SidebarGroup: MockComponent,
SidebarHeader: MockComponent,
SidebarMenu: MockComponent,
SidebarMenuItem: MockComponent,
SidebarMenuButton: MockComponent,
SidebarRail: MockComponent,
SidebarMenuSub: MockComponent,
SidebarMenuSubButton: MockComponent,
SidebarMenuSubItem: MockComponent,
useSidebar: useSidebarMock,
};
});
// Mock other UI components to avoid rendering complexity
vi.mock("~/components/ui/dropdown-menu", () => {
const MockComponent = { template: "<div><slot /></div>" };
return {
DropdownMenu: MockComponent,
DropdownMenuContent: MockComponent,
DropdownMenuGroup: MockComponent,
DropdownMenuItem: MockComponent,
DropdownMenuLabel: MockComponent,
DropdownMenuSeparator: MockComponent,
DropdownMenuTrigger: MockComponent,
};
});
vi.mock("~/components/ui/collapsible", () => {
const MockComponent = { template: "<div><slot /></div>" };
return {
Collapsible: MockComponent,
CollapsibleContent: MockComponent,
CollapsibleTrigger: MockComponent,
};
});
vi.mock("~/components/ui/avatar", () => {
const MockComponent = { template: "<div><slot /></div>" };
return {
Avatar: MockComponent,
AvatarFallback: MockComponent,
AvatarImage: MockComponent,
};
});
vi.mock("@/components/ui/sheet", () => {
const MockComponent = { template: "<div><slot /></div>" };
return {
Sheet: MockComponent,
SheetContent: MockComponent,
};
});
vi.mock("@/components/ui/sheet/SheetDescription.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/ui/sheet/SheetHeader.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("lucide-vue-next", () => {
const MockIcon = { template: '<svg class="lucide-mock" />' };
return {
BadgeCheck: MockIcon,
Bell: MockIcon,
ChevronRight: MockIcon,
ChevronsUpDown: MockIcon,
CreditCard: MockIcon,
BookOpen: MockIcon,
HandCoins: MockIcon,
LogOut: MockIcon,
Settings2: MockIcon,
Sparkles: MockIcon,
SquareTerminal: MockIcon,
};
});
describe("SidebarLayout", () => {
it("renders the header correctly", () => {
useSidebarMock.mockReturnValue({
isMobile: ref(false),
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarLayout, {
props: {
collapsible: "icon",
},
});
expect(wrapper.text()).toContain("Glowing Fiesta");
expect(wrapper.text()).toContain("v1.0.0");
});
it("renders navigation groups", () => {
const wrapper = mount(SidebarLayout);
const text = wrapper.text();
expect(text).toContain("Playground");
expect(text).toContain("Documentation");
expect(text).toContain("Settings");
});
it("renders user information", () => {
const wrapper = mount(SidebarLayout);
expect(wrapper.text()).toContain("Liviu");
expect(wrapper.text()).toContain("x.liviu@gmail.com");
});
it("renders sub-items in navigation", () => {
const wrapper = mount(SidebarLayout);
const text = wrapper.text();
// Checking sub-items of Playground
expect(text).toContain("History");
expect(text).toContain("Starred");
// Checking sub-items of Documentation
expect(text).toContain("Introduction");
expect(text).toContain("Get Started");
});
it("does not render icon if item.icon is missing", () => {
const wrapper = mount(SidebarLayout, {
props: {
navItems: [
{
title: "No Icon Item",
url: "#",
items: [],
},
],
},
});
expect(wrapper.text()).toContain("No Icon Item");
expect(wrapper.find('[data-testid="sidebar-icon"]').exists()).toBe(false);
});
it("renders correctly in mobile view", () => {
useSidebarMock.mockReturnValue({
isMobile: ref(true),
state: ref("expanded"),
openMobile: ref(true),
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarLayout);
// When isMobile is true, it renders a Sheet.
// Since we mocked Sheet components as simple divs with slots, the content should still be present.
// However, the structure is different.
// We can verify that the content is still rendered (passed through the slot).
const text = wrapper.text();
expect(text).toContain("Glowing Fiesta");
expect(text).toContain("Liviu");
// Check specific specific mock interaction if needed, or just that it doesn't crash
// and renders the menu items.
expect(text).toContain("Playground");
});
});

View file

@ -1,11 +1,52 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, beforeEach } from "vitest";
import { mount, type VueWrapper } from "@vue/test-utils";
import IndexPage from "~/pages/index.vue";
describe("pages/index.vue", () => {
const wrapper: VueWrapper = mount(IndexPage);
let wrapper: VueWrapper;
beforeEach(() => {
wrapper = mount(IndexPage);
});
it("loads without crashing", () => {
expect(wrapper.exists()).toBe(true);
});
it("displays initial state correctly", () => {
const displayElement = wrapper.find(".text-lime-500");
expect(displayElement.exists()).toBe(true);
expect(displayElement.text()).toBe("None");
});
it("updates text when Default button is clicked", async () => {
// Find button method 1: by text content inside button elements
const buttons = wrapper.findAll("button");
const defaultBtn = buttons.find((b) => b.text() === "Default");
expect(defaultBtn?.exists()).toBe(true);
await defaultBtn?.trigger("click");
expect(wrapper.find(".text-lime-500").text()).toBe("default");
});
it("updates text when other buttons are clicked", async () => {
const testCases = [
{ label: "Outline", expected: "outline" },
{ label: "Ghost", expected: "ghost" },
{ label: "Link", expected: "link" },
{ label: "Secondary", expected: "secondary" },
{ label: "Destructive", expected: "destructive" },
];
for (const { label, expected } of testCases) {
const buttons = wrapper.findAll("button");
const btn = buttons.find((b) => b.text() === label);
expect(btn?.exists(), `Button with label ${label} should exist`).toBe(true);
await btn?.trigger("click");
expect(wrapper.find(".text-lime-500").text()).toBe(expected);
}
});
});

View file

@ -32,6 +32,9 @@ export default defineConfig({
// Exclude config files
"*.config.*",
"assets/icons/**",
// Exclude UI components
"app/components/ui/**",
],
},
name: "GFiesta",