feat: L'Ami Fiduciaire V1.0.0 — full codebase with Story 0.1 complete
Initial commit of the L'Ami Fiduciaire SaaS platform built on Laravel 12, Vue 3, Inertia.js 2, and Tailwind CSS 4. Story 0.1 (rename folders to declarations in database) is implemented and code-reviewed: migration, rollback, and 6 Pest tests all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
96
resources/js/components/ui/sidebar/Sidebar.vue
Normal file
96
resources/js/components/ui/sidebar/Sidebar.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import type { SidebarProps } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
side: "left",
|
||||
variant: "sidebar",
|
||||
collapsible: "offcanvas",
|
||||
})
|
||||
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="collapsible === 'none'"
|
||||
data-slot="sidebar"
|
||||
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
:side="side"
|
||||
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
:style="{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
}"
|
||||
>
|
||||
<SheetHeader class="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="group peer text-sidebar-foreground hidden md:block"
|
||||
data-slot="sidebar"
|
||||
:data-state="state"
|
||||
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
||||
:data-variant="variant"
|
||||
:data-side="side"
|
||||
>
|
||||
<!-- This is what handles the sidebar gap on desktop -->
|
||||
<div
|
||||
:class="cn(
|
||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||
)"
|
||||
/>
|
||||
<div
|
||||
:class="cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
18
resources/js/components/ui/sidebar/SidebarContent.vue
Normal file
18
resources/js/components/ui/sidebar/SidebarContent.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
resources/js/components/ui/sidebar/SidebarFooter.vue
Normal file
18
resources/js/components/ui/sidebar/SidebarFooter.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
:class="cn('flex flex-col gap-2 p-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
resources/js/components/ui/sidebar/SidebarGroup.vue
Normal file
18
resources/js/components/ui/sidebar/SidebarGroup.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
:class="cn('relative flex w-full min-w-0 flex-col p-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
27
resources/js/components/ui/sidebar/SidebarGroupAction.vue
Normal file
27
resources/js/components/ui/sidebar/SidebarGroupAction.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
18
resources/js/components/ui/sidebar/SidebarGroupContent.vue
Normal file
18
resources/js/components/ui/sidebar/SidebarGroupContent.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
:class="cn('w-full text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
25
resources/js/components/ui/sidebar/SidebarGroupLabel.vue
Normal file
25
resources/js/components/ui/sidebar/SidebarGroupLabel.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(
|
||||
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
18
resources/js/components/ui/sidebar/SidebarHeader.vue
Normal file
18
resources/js/components/ui/sidebar/SidebarHeader.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
:class="cn('flex flex-col gap-2 p-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
resources/js/components/ui/sidebar/SidebarInput.vue
Normal file
22
resources/js/components/ui/sidebar/SidebarInput.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
:class="cn(
|
||||
'bg-background h-8 w-full shadow-none',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</Input>
|
||||
</template>
|
||||
21
resources/js/components/ui/sidebar/SidebarInset.vue
Normal file
21
resources/js/components/ui/sidebar/SidebarInset.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
:class="cn(
|
||||
'bg-background relative flex w-full flex-1 flex-col',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
18
resources/js/components/ui/sidebar/SidebarMenu.vue
Normal file
18
resources/js/components/ui/sidebar/SidebarMenu.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
:class="cn('flex w-full min-w-0 flex-col gap-1', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
35
resources/js/components/ui/sidebar/SidebarMenuAction.vue
Normal file
35
resources/js/components/ui/sidebar/SidebarMenuAction.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
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 & {
|
||||
showOnHover?: boolean
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
:class="cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
showOnHover
|
||||
&& 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
|
||||
props.class,
|
||||
)"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
26
resources/js/components/ui/sidebar/SidebarMenuBadge.vue
Normal file
26
resources/js/components/ui/sidebar/SidebarMenuBadge.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
:class="cn(
|
||||
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
|
||||
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
48
resources/js/components/ui/sidebar/SidebarMenuButton.vue
Normal file
48
resources/js/components/ui/sidebar/SidebarMenuButton.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue"
|
||||
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue"
|
||||
import { useSidebar } from "./utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
|
||||
tooltip?: string | Component
|
||||
}>(), {
|
||||
as: "button",
|
||||
variant: "default",
|
||||
size: "default",
|
||||
})
|
||||
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "tooltip")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
|
||||
<slot />
|
||||
</SidebarMenuButtonChild>
|
||||
|
||||
<Tooltip v-else>
|
||||
<TooltipTrigger as-child>
|
||||
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
|
||||
<slot />
|
||||
</SidebarMenuButtonChild>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
:hidden="state !== 'collapsed' || isMobile"
|
||||
>
|
||||
<template v-if="typeof tooltip === 'string'">
|
||||
{{ tooltip }}
|
||||
</template>
|
||||
<component :is="tooltip" v-else />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { SidebarMenuButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { sidebarMenuButtonVariants } from "."
|
||||
|
||||
export interface SidebarMenuButtonProps extends PrimitiveProps {
|
||||
variant?: SidebarMenuButtonVariants["variant"]
|
||||
size?: SidebarMenuButtonVariants["size"]
|
||||
isActive?: boolean
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SidebarMenuButtonProps>(), {
|
||||
as: "button",
|
||||
variant: "default",
|
||||
size: "default",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
:data-size="size"
|
||||
:data-active="isActive"
|
||||
:class="cn(sidebarMenuButtonVariants({ variant, size }), props.class)"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
18
resources/js/components/ui/sidebar/SidebarMenuItem.vue
Normal file
18
resources/js/components/ui/sidebar/SidebarMenuItem.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
:class="cn('group/menu-item relative', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
35
resources/js/components/ui/sidebar/SidebarMenuSkeleton.vue
Normal file
35
resources/js/components/ui/sidebar/SidebarMenuSkeleton.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { computed } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const props = defineProps<{
|
||||
showIcon?: boolean
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const width = computed(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
:class="cn('flex h-8 items-center gap-2 rounded-md px-2', props.class)"
|
||||
>
|
||||
<Skeleton
|
||||
v-if="showIcon"
|
||||
class="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
|
||||
<Skeleton
|
||||
class="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
:style="{ '--skeleton-width': width }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
22
resources/js/components/ui/sidebar/SidebarMenuSub.vue
Normal file
22
resources/js/components/ui/sidebar/SidebarMenuSub.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-badge"
|
||||
:class="cn(
|
||||
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
36
resources/js/components/ui/sidebar/SidebarMenuSubButton.vue
Normal file
36
resources/js/components/ui/sidebar/SidebarMenuSubButton.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
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 & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
as: "a",
|
||||
size: "md",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:data-size="size"
|
||||
:data-active="isActive"
|
||||
:class="cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
18
resources/js/components/ui/sidebar/SidebarMenuSubItem.vue
Normal file
18
resources/js/components/ui/sidebar/SidebarMenuSubItem.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
:class="cn('group/menu-sub-item relative', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
82
resources/js/components/ui/sidebar/SidebarProvider.vue
Normal file
82
resources/js/components/ui/sidebar/SidebarProvider.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes, Ref } from "vue"
|
||||
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core"
|
||||
import { TooltipProvider } from "reka-ui"
|
||||
import { computed, ref } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils"
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
|
||||
open: undefined,
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
"update:open": [open: boolean]
|
||||
}>()
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 768px)")
|
||||
const openMobile = ref(false)
|
||||
|
||||
const open = useVModel(props, "open", emits, {
|
||||
defaultValue: props.defaultOpen ?? false,
|
||||
passive: (props.open === undefined) as false,
|
||||
}) as Ref<boolean>
|
||||
|
||||
function setOpen(value: boolean) {
|
||||
open.value = value // emits('update:open', value)
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
}
|
||||
|
||||
function setOpenMobile(value: boolean) {
|
||||
openMobile.value = value
|
||||
}
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
function toggleSidebar() {
|
||||
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
|
||||
}
|
||||
|
||||
useEventListener("keydown", (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
})
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = computed(() => open.value ? "expanded" : "collapsed")
|
||||
|
||||
provideSidebarContext({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
:style="{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
}"
|
||||
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
33
resources/js/components/ui/sidebar/SidebarRail.vue
Normal file
33
resources/js/components/ui/sidebar/SidebarRail.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSidebar } from "./utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const { toggleSidebar } = useSidebar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
:tabindex="-1"
|
||||
title="Toggle Sidebar"
|
||||
:class="cn(
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
|
||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||
props.class,
|
||||
)"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
19
resources/js/components/ui/sidebar/SidebarSeparator.vue
Normal file
19
resources/js/components/ui/sidebar/SidebarSeparator.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
:class="cn('bg-sidebar-border mx-2 w-auto', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Separator>
|
||||
</template>
|
||||
28
resources/js/components/ui/sidebar/SidebarTrigger.vue
Normal file
28
resources/js/components/ui/sidebar/SidebarTrigger.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSidebar } from "./utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const { isMobile, state, toggleSidebar } = useSidebar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="cn('h-7 w-7', props.class)"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<PanelLeftOpen v-if="isMobile || state === 'collapsed'" />
|
||||
<PanelLeftClose v-else />
|
||||
<span class="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
</template>
|
||||
60
resources/js/components/ui/sidebar/index.ts
Normal file
60
resources/js/components/ui/sidebar/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export interface SidebarProps {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
export { default as Sidebar } from "./Sidebar.vue"
|
||||
export { default as SidebarContent } from "./SidebarContent.vue"
|
||||
export { default as SidebarFooter } from "./SidebarFooter.vue"
|
||||
export { default as SidebarGroup } from "./SidebarGroup.vue"
|
||||
export { default as SidebarGroupAction } from "./SidebarGroupAction.vue"
|
||||
export { default as SidebarGroupContent } from "./SidebarGroupContent.vue"
|
||||
export { default as SidebarGroupLabel } from "./SidebarGroupLabel.vue"
|
||||
export { default as SidebarHeader } from "./SidebarHeader.vue"
|
||||
export { default as SidebarInput } from "./SidebarInput.vue"
|
||||
export { default as SidebarInset } from "./SidebarInset.vue"
|
||||
export { default as SidebarMenu } from "./SidebarMenu.vue"
|
||||
export { default as SidebarMenuAction } from "./SidebarMenuAction.vue"
|
||||
export { default as SidebarMenuBadge } from "./SidebarMenuBadge.vue"
|
||||
export { default as SidebarMenuButton } from "./SidebarMenuButton.vue"
|
||||
export { default as SidebarMenuItem } from "./SidebarMenuItem.vue"
|
||||
export { default as SidebarMenuSkeleton } from "./SidebarMenuSkeleton.vue"
|
||||
export { default as SidebarMenuSub } from "./SidebarMenuSub.vue"
|
||||
export { default as SidebarMenuSubButton } from "./SidebarMenuSubButton.vue"
|
||||
export { default as SidebarMenuSubItem } from "./SidebarMenuSubItem.vue"
|
||||
export { default as SidebarProvider } from "./SidebarProvider.vue"
|
||||
export { default as SidebarRail } from "./SidebarRail.vue"
|
||||
export { default as SidebarSeparator } from "./SidebarSeparator.vue"
|
||||
export { default as SidebarTrigger } from "./SidebarTrigger.vue"
|
||||
|
||||
export { useSidebar } from "./utils"
|
||||
|
||||
export const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type SidebarMenuButtonVariants = VariantProps<typeof sidebarMenuButtonVariants>
|
||||
21
resources/js/components/ui/sidebar/utils.ts
Normal file
21
resources/js/components/ui/sidebar/utils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext } from "reka-ui"
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
|
||||
export type SidebarContext = {
|
||||
state: ComputedRef<'expanded' | 'collapsed'>;
|
||||
open: Ref<boolean>;
|
||||
setOpen: (value: boolean) => void;
|
||||
isMobile: Ref<boolean>;
|
||||
openMobile: Ref<boolean>;
|
||||
setOpenMobile: (value: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
export const SIDEBAR_WIDTH = "16rem"
|
||||
export const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
export const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar")
|
||||
Reference in New Issue
Block a user