Initial Commit

This commit is contained in:
2026-10-03 20:07:49 +02:00
commit a1346c98b1
427 changed files with 59320 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AccordionRoot,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AccordionRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AccordionRoot
v-slot="slotProps"
data-slot="accordion"
v-bind="forwarded"
:class="cn('overflow-hidden rounded-2xl border flex w-full flex-col', props.class)"
>
<slot v-bind="slotProps" />
</AccordionRoot>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { AccordionContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AccordionContent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionContent
data-slot="accordion-content"
v-bind="delegatedProps"
class="data-open:animate-accordion-down data-closed:animate-accordion-up px-4 text-sm overflow-hidden"
>
<div
:class="cn(
'pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
props.class,
)"
>
<slot />
</div>
</AccordionContent>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { AccordionItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AccordionItem, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
v-slot="slotProps"
data-slot="accordion-item"
v-bind="forwardedProps"
:class="cn('data-open:bg-muted/50 not-last:border-b', props.class)"
>
<slot v-bind="slotProps" />
</AccordionItem>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AccordionHeader,
AccordionTrigger,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
data-slot="accordion-trigger"
v-bind="delegatedProps"
:class="
cn(
'**:data-[slot=accordion-trigger-icon]:text-muted-foreground gap-6 p-4 text-left text-sm font-medium hover:underline **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50',
props.class,
)
"
>
<slot />
<slot name="icon">
<Icon name="ph:caret-down-fill" data-slot="accordion-trigger-icon" class="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<Icon name="ph:caret-up-fill" data-slot="accordion-trigger-icon" class="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AlertVariants } from '.'
import { cn } from '@/lib/utils'
import { alertVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
variant?: AlertVariants['variant']
}>()
</script>
<template>
<div
data-slot="alert"
:class="cn(alertVariants({ variant }), props.class)"
role="alert"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<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="alert-action"
:class="cn('absolute top-2.5 right-3', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<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="alert-description"
:class="cn('text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<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="alert-title"
:class="cn('font-medium group-has-[>svg]/alert:col-start-2 cn-font-heading [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertAction } from './AlertAction.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export { default as AlertTitle } from './AlertTitle.vue'
export const alertVariants = cva('grid gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*=size-])]:size-4 group/alert relative w-full', {
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive: 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
})
export type AlertVariants = VariantProps<typeof alertVariants>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AvatarVariants } from '.'
import { AvatarRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
import { avatarVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
size?: AvatarVariants['size']
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:data-size="size ?? 'default'"
:class="cn(avatarVariants({ size }), props.class)"
>
<slot />
</AvatarRoot>
</template>
+23
View File
@@ -0,0 +1,23 @@
<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="avatar-badge"
:class="cn(
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
props.class,
)"
>
<slot />
</span>
</template>
@@ -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 text-muted-foreground rounded-sm flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs', props.class)"
>
<slot />
</AvatarFallback>
</template>
+20
View File
@@ -0,0 +1,20 @@
<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="avatar-group"
:class="cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
props.class,
)"
>
<slot />
</div>
</template>
@@ -0,0 +1,20 @@
<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="avatar-group-count"
:class="cn(
'bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background',
props.class,
)"
>
<slot />
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<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="rounded-sm aspect-square size-full object-cover"
>
<slot />
</AvatarImage>
</template>
+27
View File
@@ -0,0 +1,27 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Avatar } from './Avatar.vue'
export { default as AvatarBadge } from './AvatarBadge.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'
export { default as AvatarGroup } from './AvatarGroup.vue'
export { default as AvatarGroupCount } from './AvatarGroupCount.vue'
export { default as AvatarImage } from './AvatarImage.vue'
export const avatarVariants = cva(
'size-8 rounded-sm after:rounded-sm data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten',
{
variants: {
size: {
sm: '',
default: '',
lg: '',
},
},
defaultVariants: {
size: 'default',
},
},
)
export type AvatarVariants = VariantProps<typeof avatarVariants>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Primitive
data-slot="badge"
:data-variant="variant"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'h-5 gap-1 rounded-2xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
@@ -0,0 +1,18 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
:class="cn('', props.class)"
>
<slot />
</nav>
</template>
@@ -0,0 +1,23 @@
<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-ellipsis"
role="presentation"
aria-hidden="true"
:class="cn('size-5 [&>svg]:size-4 flex items-center justify-center', props.class)"
>
<slot>
<Icon name="ph:dots-three-fill" />
</slot>
<span class="sr-only">More</span>
</span>
</template>
@@ -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>
<li
data-slot="breadcrumb-item"
:class="cn('gap-1.5 inline-flex items-center', props.class)"
>
<slot />
</li>
</template>
@@ -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>
@@ -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 gap-1.5 text-sm sm:gap-2.5 flex flex-wrap items-center wrap-break-word', props.class)"
>
<slot />
</ol>
</template>
@@ -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>
@@ -0,0 +1,22 @@
<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-separator"
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:size-3.5', props.class)"
>
<slot>
<Icon name="ph:caret-right-fill" class="cn-rtl-flip" />
</slot>
</li>
</template>
+7
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'
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+35
View File
@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px cursor-pointer [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline: 'border-border bg-background dark:bg-transparent hover:bg-muted hover:text-foreground dark:hover:bg-input/30 aria-expanded:bg-muted aria-expanded:text-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
'default': 'h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
'xs': 'h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*=size-])]:size-3',
'sm': 'h-7 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'lg': 'h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
'icon': 'size-8',
'icon-xs': 'size-6 [&_svg:not([class*=size-])]:size-3',
'icon-sm': 'size-7',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
size?: 'default' | 'sm'
}>(), {
size: 'default',
})
</script>
<template>
<div
data-slot="card"
:data-size="size"
:class="cn('bg-card text-card-foreground ring-foreground/5 dark:ring-foreground/10 gap-(--card-spacing) overflow-hidden rounded-[min(var(--radius-4xl),24px)] py-(--card-spacing) text-sm shadow-sm ring-1 [--card-spacing:--spacing(5)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)] group/card flex flex-col', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<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="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<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="card-content"
:class="cn('px-(--card-spacing)', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<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="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<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="card-footer"
:class="cn('rounded-b-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-t]:pt-(--card-spacing) flex items-center', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<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="card-header"
:class="cn('gap-1.5 rounded-t-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<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="card-title"
:class="cn('text-base font-medium cn-font-heading', props.class)"
>
<slot />
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from './Card.vue'
export { default as CardAction } from './CardAction.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
@@ -0,0 +1,59 @@
<script lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ChartConfig } from '.'
import { useId } from 'reka-ui'
import { computed, toRefs } from 'vue'
import { cn } from '@/lib/utils'
import { provideChartContext } from '.'
import ChartStyle from './ChartStyle.vue'
</script>
<script setup lang="ts">
const props = defineProps<{
id?: HTMLAttributes['id']
class?: HTMLAttributes['class']
config: ChartConfig
cursor?: boolean
}>()
defineSlots<{
default: {
id: string
config: ChartConfig
}
}>()
const { config } = toRefs(props)
const uniqueId = useId()
const chartId = computed(() => `chart-${props.id || uniqueId.replace(/:/g, '')}`)
provideChartContext({
id: uniqueId,
config,
})
</script>
<template>
<div
data-slot="chart"
:data-chart="chartId"
:class="cn(
`cn-chart [&_.tick_text]:!fill-muted-foreground [&_.tick_line]:!stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex flex-col aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden [&_[data-vis-xy-container]]:h-full [&_[data-vis-single-container]]:h-full h-full [&_[data-vis-xy-container]]:w-full [&_[data-vis-single-container]]:w-full w-full `,
props.class,
)"
:style="{
'--vis-tooltip-padding': '0px',
'--vis-tooltip-background-color': 'transparent',
'--vis-tooltip-border-color': 'transparent',
'--vis-tooltip-text-color': 'none',
'--vis-tooltip-shadow-color': 'none',
'--vis-tooltip-backdrop-filter': 'none',
'--vis-crosshair-circle-stroke-color': '#0000',
'--vis-crosshair-line-stroke-width': cursor ? '1px' : '0px',
'--vis-font-family': 'var(--font-sans)',
}"
>
<slot :id="uniqueId" :config="config" />
<ChartStyle :id="chartId" />
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { useChart } from '.'
const props = withDefaults(defineProps<{
hideIcon?: boolean
nameKey?: string
verticalAlign?: 'bottom' | 'top'
// payload?: any[]
class?: HTMLAttributes['class']
}>(), {
verticalAlign: 'bottom',
})
const { id, config } = useChart()
const payload = computed(() => Object.entries(config.value).map(([key, value]) => {
return {
key: props.nameKey || key,
itemConfig: config.value[key],
}
}))
const containerSelector = ref('')
onMounted(() => {
containerSelector.value = `[data-chart="chart-${id}"]>[data-vis-xy-container]`
})
</script>
<template>
<div
v-if="containerSelector"
:class="cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
props.class,
)"
>
<div
v-for="{ key, itemConfig } in payload"
:key="key"
:class="cn(
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
)"
>
<component :is="itemConfig?.icon" v-if="itemConfig?.icon" />
<div
v-else
class="h-2 w-2 shrink-0 rounded-xs"
:style="{
backgroundColor: itemConfig?.color,
}"
/>
{{ itemConfig?.label }}
</div>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { Primitive } from 'reka-ui'
import { computed } from 'vue'
import { THEMES, useChart } from '.'
defineProps<{
id?: HTMLAttributes['id']
}>()
const { config } = useChart()
const colorConfig = computed(() => {
return Object.entries(config.value).filter(
([, config]) => config.theme || config.color,
)
})
</script>
<template>
<Primitive
v-if="colorConfig.length"
as="style"
>
{{ Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color
= itemConfig.theme?.[theme as keyof typeof itemConfig.theme]
|| itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`,
)
.join("\n") }}
</Primitive>
</template>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ChartConfig } from '.'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string
labelKey?: string
labelFormatter?: (d: number | Date) => string
payload?: Record<string, any>
config?: ChartConfig
class?: HTMLAttributes['class']
color?: string
x?: number | Date
}>(), {
payload: () => ({}),
config: () => ({}),
indicator: 'dot',
})
// TODO: currently we use `createElement` and `render` to render the
// const chartContext = useChart(null)
const payload = computed(() => {
return Object.entries(props.payload).map(([key, value]) => {
// const key = `${props.nameKey || item.name || item.dataKey || "value"}`
const itemConfig = props.config[key]
const indicatorColor = props.config[key]?.color ?? props.payload.fill
return { key, value, itemConfig, indicatorColor }
}).filter(i => i.itemConfig)
})
const nestLabel = computed(() => Object.keys(props.payload).length === 1 && props.indicator !== 'dot')
const tooltipLabel = computed(() => {
if (props.hideLabel)
return null
if (props.labelFormatter && props.x !== undefined) {
return props.labelFormatter(props.x)
}
return props.labelKey ? props.config[props.labelKey]?.label || props.payload[props.labelKey] : props.x
})
</script>
<template>
<div
:class="cn(
'bg-popover text-popover-foreground ring-foreground/5 dark:ring-foreground/10 gap-1.5 rounded-xl px-2.5 py-1.5 text-xs shadow-lg ring-1 grid min-w-32 items-start',
props.class,
)"
>
<slot>
<div v-if="!nestLabel && tooltipLabel" class="font-medium">
{{ tooltipLabel }}
</div>
<div class="grid gap-1.5">
<div
v-for="{ value, itemConfig, indicatorColor, key } in payload"
:key="key"
:class="
cn('[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center')"
>
<component :is="itemConfig.icon" v-if="itemConfig?.icon" />
<template v-else-if="!hideIndicator">
<div
:class="cn(
'shrink-0 rounded-xs border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)"
:style="{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
}"
/>
</template>
<div :class="cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')">
<div class="grid gap-1.5">
<div v-if="nestLabel" class="font-medium">
{{ tooltipLabel }}
</div>
<span class="text-muted-foreground">
{{ itemConfig?.label || value }}
</span>
</div>
<span v-if="value" class="text-foreground font-mono font-medium tabular-nums">
{{ value.toLocaleString() }}
</span>
</div>
</div>
</div>
</slot>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
import type { Component, Ref } from 'vue'
import { createContext } from 'reka-ui'
export { default as ChartContainer } from './ChartContainer.vue'
export { default as ChartLegendContent } from './ChartLegendContent.vue'
export { default as ChartTooltipContent } from './ChartTooltipContent.vue'
export { componentToString } from './utils'
// Format: { THEME_NAME: CSS_SELECTOR }
export const THEMES = { light: '', dark: '.dark' } as const
export type ChartConfig = {
[k in string]: {
label?: string | Component
icon?: string | Component
} & (
| { color?: string, theme?: never }
| { color?: never, theme: Record<keyof typeof THEMES, string> }
)
}
interface ChartContextProps {
id: string
config: Ref<ChartConfig>
}
export const [useChart, provideChartContext] = createContext<ChartContextProps>('Chart')
export { VisCrosshair as ChartCrosshair, VisTooltip as ChartTooltip } from '@unovis/vue'
+44
View File
@@ -0,0 +1,44 @@
import type { ChartConfig } from '.'
import { isClient } from '@vueuse/core'
import { useId } from 'reka-ui'
import { h, render } from 'vue'
// Simple cache using a Map to store serialized object keys
const cache = new Map<string, string>()
// Convert object to a consistent string key
function serializeKey(key: Record<string, any>): string {
return JSON.stringify(key, Object.keys(key).sort())
}
interface Constructor<P = any> {
__isFragment?: never
__isTeleport?: never
__isSuspense?: never
new (...args: any[]): {
$props: P
}
}
export function componentToString<P>(config: ChartConfig, component: Constructor<P>, props?: P) {
if (!isClient)
return
// This function will be called once during mount lifecycle
const id = useId()
// https://unovis.dev/docs/auxiliary/Crosshair#component-props
return (_data: any, x: number | Date) => {
const data = 'data' in _data ? _data.data : _data
const serializedKey = `${id}-${serializeKey(data)}`
const cachedContent = cache.get(serializedKey)
if (cachedContent)
return cachedContent
const vnode = h<unknown>(component, { ...props, payload: data, config, x })
const div = document.createElement('div')
render(vnode, div)
cache.set(serializedKey, div.innerHTML)
return div.innerHTML
}
}
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-slot="slotProps"
data-slot="checkbox"
v-bind="forwarded"
:class="cn('bg-input/90 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary border-transparent aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[5px] border transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
>
<slot v-bind="slotProps">
<Icon name="ph:check-bold" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Checkbox } from './Checkbox.vue'
@@ -0,0 +1,453 @@
<script setup lang="ts">
import type { HexColor, HslaColor, HsvaColor, RgbaColor } from "@uiw/color-convert";
import {
hexToHsva,
hslaToHsva,
hsvaToHex,
hsvaToHsla,
hsvaToRgba,
rgbaToHsva,
} from "@uiw/color-convert";
import { computed, ref, watch } from "vue";
import ContrastRatio from "./ContrastRatio.vue";
import ObjectColorInput from "./ObjectColorInput.vue";
export interface ColorPickerValue {
hex: string;
hsl: HslaColor;
hsla: HslaColor;
rgb: RgbaColor;
rgba: RgbaColor;
}
export interface ColorPickerProps {
value?: `#${string}` | HsvaColor | HslaColor | RgbaColor;
type?: "hsl" | "hsla" | "rgb" | "rgba" | "hex";
swatches?: HexColor[];
hideContrastRatio?: boolean;
hideDefaultSwatches?: boolean;
class?: string;
open?: boolean;
}
const props = withDefaults(defineProps<ColorPickerProps>(), {
type: "hsl",
swatches: () => [],
hideContrastRatio: false,
hideDefaultSwatches: false,
open: false,
});
const emit = defineEmits<{
"value-change": [value: ColorPickerValue];
"update:open": [value: boolean];
}>();
const colorType = ref(props.type);
const isDropdownOpen = ref(false);
const isOpen = ref(props.open);
const colorOptions = [
{ value: "hex", label: "HEX" },
{ value: "hsl", label: "HSL" },
{ value: "hsla", label: "HSLA" },
{ value: "rgb", label: "RGB" },
{ value: "rgba", label: "RGBA" },
];
function getColorAsHsva(color: ColorPickerProps["value"]): HsvaColor {
if (!color) return { h: 0, s: 0, v: 0, a: 1 };
if (typeof color === "string") {
return hexToHsva(color);
} else if ("h" in color && "s" in color && "v" in color) {
return color;
} else if ("r" in color) {
return rgbaToHsva(color);
} else {
return hslaToHsva(color);
}
}
const colorHsv = ref<HsvaColor>(getColorAsHsva(props.value));
watch(
() => props.value,
(newValue) => {
if (newValue) {
colorHsv.value = getColorAsHsva(newValue);
}
},
{ deep: true },
);
watch(
() => props.open,
(newValue) => {
isOpen.value = newValue;
},
);
watch(isOpen, (newValue) => {
emit("update:open", newValue);
});
const hexValue = computed(() => hsvaToHex(colorHsv.value));
const hslValue = computed(() => hsvaToHsla(colorHsv.value));
const rgbValue = computed(() => hsvaToRgba(colorHsv.value));
const defaultSwatches = ["#F8371A", "#F97C1B", "#FAC81C", "#3FD0B6", "#2CADF6", "#6462FC"];
const sortedSwatches = computed(() => {
const allSwatches = [...defaultSwatches, ...props.swatches];
return allSwatches.sort((a, b) => hexToHsva(a).h - hexToHsva(b).h);
});
const isDraggingSaturation = ref(false);
const isDraggingHue = ref(false);
const isDraggingAlpha = ref(false);
const saturationRef = ref<HTMLElement>();
const hueRef = ref<HTMLElement>();
const alphaRef = ref<HTMLElement>();
function handleValueChange(color: HsvaColor) {
colorHsv.value = color;
const hslColor = hsvaToHsla(color);
const rgbColor = hsvaToRgba(color);
emit("value-change", {
hex: hsvaToHex(color),
hsl: {
...hslColor,
a: Math.round(hslColor.a * 100) / 100,
},
hsla: {
...hslColor,
a: Math.round(hslColor.a * 100) / 100,
},
rgb: {
...rgbColor,
a: Math.round(rgbColor.a * 100) / 100,
},
rgba: {
...rgbColor,
a: Math.round(rgbColor.a * 100) / 100,
},
});
}
function updateFromHex(value: string | number) {
try {
const newColor = hexToHsva(String(value));
handleValueChange(newColor);
} catch (error) {
// Invalid hex color, ignore
}
}
function updateFromObject(value: HslaColor | RgbaColor) {
try {
const newColor = "r" in value ? rgbaToHsva(value) : hslaToHsva(value);
handleValueChange(newColor);
} catch (error) {
// Invalid color object, ignore
}
}
function setColorFromHex(hex: string) {
handleValueChange(hexToHsva(hex));
}
function toggleDropdown() {
isDropdownOpen.value = !isDropdownOpen.value;
}
function selectColorType(value: string) {
colorType.value = value as "hsl" | "hsla" | "rgb" | "rgba" | "hex";
isDropdownOpen.value = false;
}
function startSaturationDrag(event: MouseEvent | TouchEvent) {
event.preventDefault();
isDraggingSaturation.value = true;
updateSaturation(event);
function handleMove(e: MouseEvent | TouchEvent) {
if (isDraggingSaturation.value) {
updateSaturation(e);
}
}
function handleEnd() {
isDraggingSaturation.value = false;
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleEnd);
document.addEventListener("touchmove", handleMove);
document.addEventListener("touchend", handleEnd);
}
function updateSaturation(event: MouseEvent | TouchEvent) {
if (!saturationRef.value) return;
const rect = saturationRef.value.getBoundingClientRect();
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const clientY = "touches" in event ? event.touches[0].clientY : event.clientY;
const relativeX = (clientX - rect.left) / rect.width;
const relativeY = (clientY - rect.top) / rect.height;
const x = Math.max(0.01, Math.min(0.99, relativeX));
const y = Math.max(0.01, Math.min(0.99, relativeY));
handleValueChange({
...colorHsv.value,
s: x * 100,
v: (1 - y) * 100,
});
}
function handleHueClick(event: MouseEvent) {
updateHue(event);
}
function startHueDrag(event: MouseEvent | TouchEvent) {
event.preventDefault();
isDraggingHue.value = true;
updateHue(event);
function handleMove(e: MouseEvent | TouchEvent) {
if (isDraggingHue.value) {
updateHue(e);
}
}
function handleEnd() {
isDraggingHue.value = false;
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleEnd);
document.addEventListener("touchmove", handleMove);
document.addEventListener("touchend", handleEnd);
}
function updateHue(event: MouseEvent | TouchEvent) {
if (!hueRef.value) return;
const rect = hueRef.value.getBoundingClientRect();
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
handleValueChange({
...colorHsv.value,
h: x * 360,
});
}
function startAlphaDrag(event: MouseEvent | TouchEvent) {
event.preventDefault();
isDraggingAlpha.value = true;
updateAlpha(event);
function handleMove(e: MouseEvent | TouchEvent) {
if (isDraggingAlpha.value) {
updateAlpha(e);
}
}
function handleEnd() {
isDraggingAlpha.value = false;
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleEnd);
document.addEventListener("touchmove", handleMove);
document.addEventListener("touchend", handleEnd);
}
function updateAlpha(event: MouseEvent | TouchEvent) {
if (!alphaRef.value) return;
const rect = alphaRef.value.getBoundingClientRect();
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
handleValueChange({
...colorHsv.value,
a: x,
});
}
</script>
<template>
<div class="relative">
<div @click="isOpen = !isOpen">
<slot />
</div>
<div
v-if="isOpen"
class="bg-popover absolute top-full left-0 z-50 mt-2 w-80 max-w-[calc(100vw-3rem)] rounded-md border p-4 shadow-md"
>
<div class="space-y-4">
<div
ref="saturationRef"
class="relative aspect-4/2 w-full cursor-crosshair rounded border"
:style="{
background: `linear-gradient(to right, #fff, hsl(${colorHsv.h}, 100%, 50%)), linear-gradient(to top, #000, transparent)`,
}"
@mousedown="startSaturationDrag"
@touchstart="startSaturationDrag"
>
<div
class="pointer-events-none absolute h-4 w-4 rounded-full border-2 border-white"
:style="{
left: `${colorHsv.s}%`,
top: `${100 - colorHsv.v}%`,
transform: 'translate(-50%, -50%)',
}"
/>
</div>
<div
ref="hueRef"
class="relative h-4 w-full cursor-pointer rounded"
:style="{
background:
'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)',
}"
@mousedown="startHueDrag"
@touchstart="startHueDrag"
@click="handleHueClick"
>
<div
class="border-foreground pointer-events-none absolute h-4 w-4 rounded-full border-2 bg-white"
:style="{
left: `${(colorHsv.h / 360) * 100}%`,
transform: 'translate(-50%, -50%)',
top: '50%',
}"
/>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<button
type="button"
class="border-input bg-background inline-flex h-8 w-20 items-center justify-between gap-2 rounded-md border px-2 py-1 text-xs font-medium uppercase"
@click="toggleDropdown"
>
<span>{{ colorType }}</span>
<Icon name="ph:caret-down-fill" class="size-3.5" />
</button>
<div
v-if="isDropdownOpen"
class="border-input bg-popover absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-md"
>
<div class="p-1">
<button
v-for="option in colorOptions"
:key="option.value"
type="button"
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-xs font-medium uppercase select-none"
@click="selectColorType(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div class="flex grow">
<input
v-if="colorType === 'hex'"
:value="hexValue"
type="text"
class="border-input bg-background placeholder:text-muted-foreground flex h-8 w-full rounded-md border px-3 py-1 text-sm focus:outline-none"
placeholder="#000000"
@input="updateFromHex(($event.target as HTMLInputElement).value)"
/>
<ObjectColorInput
v-else
:value="colorType === 'hsl' || colorType === 'hsla' ? hslValue : rgbValue"
:label="colorType"
@value-change="updateFromObject"
/>
</div>
</div>
<div
v-if="colorType === 'rgba' || colorType === 'hsla'"
class="space-y-2"
>
<div
ref="alphaRef"
class="relative h-4 w-full cursor-pointer rounded"
:style="{
background: `linear-gradient(to right, rgba(${rgbValue.r}, ${rgbValue.g}, ${rgbValue.b}, 0) 0%, rgba(${rgbValue.r}, ${rgbValue.g}, ${rgbValue.b}, 1) 100%), repeating-conic-gradient(#ccc 0% 25%, transparent 0% 50%) 50% / 8px 8px`,
}"
@mousedown="startAlphaDrag"
@touchstart="startAlphaDrag"
>
<div
class="border-foreground pointer-events-none absolute h-4 w-4 rounded-full border-2 bg-white"
:style="{
left: `${colorHsv.a * 100}%`,
transform: 'translate(-50%, -50%)',
top: '50%',
}"
/>
</div>
</div>
<div v-if="swatches.length > 0 || !hideDefaultSwatches">
<div class="bg-border h-px" />
<div
v-if="!hideDefaultSwatches"
class="flex flex-wrap justify-start gap-2 pt-2"
>
<button
v-for="color in sortedSwatches"
:key="`${color}-swatch`"
type="button"
class="ring-offset-background h-5 w-5 shrink-0 cursor-pointer rounded ring-2 ring-transparent ring-offset-1 transition-all duration-100 hover:ring-current"
:style="{ backgroundColor: color }"
:aria-label="`Set color to ${color}`"
@click="setColorFromHex(color)"
@keydown.enter="setColorFromHex(color)"
/>
</div>
</div>
<div v-if="!hideContrastRatio">
<div class="bg-border h-px" />
<ContrastRatio :color="colorHsv" />
</div>
</div>
</div>
<div
v-if="isOpen"
class="fixed inset-0 z-40"
@click="isOpen = false"
/>
</div>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge';
import type { HsvaColor } from "@uiw/color-convert";
import { hsvaToRgba } from "@uiw/color-convert";
import { computed, ref, watch } from "vue";
export interface ContrastRatioProps {
color: HsvaColor;
}
const props = defineProps<ContrastRatioProps>();
const darkModeContrastRatio = ref(0);
const lightModeContrastValue = ref(0);
const rgbaColor = computed(() => hsvaToRgba(props.color));
const shouldUseWhiteText = computed(() => {
const rgb = rgbaColor.value;
// Calculate luminance of the color background
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const rLinear = r <= 0.03928 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4;
const gLinear = g <= 0.03928 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4;
const bLinear = b <= 0.03928 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4;
const luminance = 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear;
// Use white text if the background is dark (luminance < 0.5)
return luminance < 0.5;
});
const currentContrastRatio = computed(() => {
// For accessibility, use the better of the two contrast ratios (dark or light mode)
// This gives the most accurate assessment of accessibility across different contexts
return Math.max(darkModeContrastRatio.value, lightModeContrastValue.value);
});
const isAccessible = computed(() => {
return {
aa: currentContrastRatio.value >= 4.5,
aaa: currentContrastRatio.value >= 7,
};
});
function calculateContrastRatios(color: HsvaColor) {
const rgb = hsvaToRgba(color);
function toSRGB(c: number) {
const channel = c / 255;
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
}
function alphaBlend(foreground: number, background: number, alpha: number) {
return foreground * alpha + background * (1 - alpha);
}
// Apply alpha blending for light background (white: 255)
const lightR = alphaBlend(rgb.r, 255, rgb.a);
const lightG = alphaBlend(rgb.g, 255, rgb.a);
const lightB = alphaBlend(rgb.b, 255, rgb.a);
// Apply alpha blending for dark background (dark gray: 32)
const darkR = alphaBlend(rgb.r, 32, rgb.a);
const darkG = alphaBlend(rgb.g, 32, rgb.a);
const darkB = alphaBlend(rgb.b, 32, rgb.a);
// Calculate luminance for light background composition
const lightRSRGB = toSRGB(lightR);
const lightGSRGB = toSRGB(lightG);
const lightBSRGB = toSRGB(lightB);
const lightLuminance = 0.2126 * lightRSRGB + 0.7152 * lightGSRGB + 0.0722 * lightBSRGB;
// Calculate luminance for dark background composition
const darkRSRGB = toSRGB(darkR);
const darkGSRGB = toSRGB(darkG);
const darkBSRGB = toSRGB(darkB);
const darkLuminance = 0.2126 * darkRSRGB + 0.7152 * darkGSRGB + 0.0722 * darkBSRGB;
// Text color luminances (what we're measuring contrast against)
const whiteTextLuminance = 1.0;
const blackTextLuminance = 0.0;
// Calculate contrast ratios: text against the color background (alpha-blended)
// For dark mode: white text against the dark-composited color
const darkModeRatio =
(Math.max(whiteTextLuminance, darkLuminance) + 0.05) /
(Math.min(whiteTextLuminance, darkLuminance) + 0.05);
// For light mode: black text against the light-composited color
const lightModeRatio =
(Math.max(blackTextLuminance, lightLuminance) + 0.05) /
(Math.min(blackTextLuminance, lightLuminance) + 0.05);
darkModeContrastRatio.value = Number(darkModeRatio.toFixed(2));
lightModeContrastValue.value = Number(lightModeRatio.toFixed(2));
}
watch(() => props.color, calculateContrastRatios, { immediate: true, deep: true });
</script>
<template>
<div class="flex items-center justify-between gap-4 pt-2 select-none">
<div class="flex items-center gap-4">
<div
class="flex size-10 items-center justify-center rounded border"
:style="{
backgroundColor: `rgba(${rgbaColor.r}, ${rgbaColor.g}, ${rgbaColor.b}, ${rgbaColor.a})`,
backgroundSize: '8px 8px',
}"
>
<span
class="font-medium"
:style="{
color: shouldUseWhiteText ? 'white' : 'black',
}"
>A</span
>
</div>
<div class="flex flex-col justify-between">
<span class="text-muted-foreground text-xs text-nowrap whitespace-nowrap">
Contrast Ratio
</span>
<span class="text-sm">{{ currentContrastRatio }}</span>
</div>
</div>
<div class="flex items-center justify-end gap-1 *:select-none">
<Badge
:variant="isAccessible.aa ? 'default' : 'secondary'"
class="gap-1"
>
<Icon name="ph:check-fill" v-if="isAccessible.aa" class="size-3" />
<Icon name="ph:x-fill" v-else class="size-3" />
AA
</Badge>
<Badge
:variant="isAccessible.aaa ? 'default' : 'secondary'"
class="gap-1"
>
<Icon name="ph:check-fill" v-if="isAccessible.aaa" class="size-3" />
<Icon name="ph:x-fill" v-else class="size-3" />
AAA
</Badge>
</div>
</div>
</template>
@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { HslaColor, RgbaColor } from "@uiw/color-convert";
import { computed } from "vue";
interface ObjectColorInputProps {
label: "hsl" | "hsla" | "rgb" | "rgba";
value: HslaColor | RgbaColor;
}
const props = defineProps<ObjectColorInputProps>();
const emit = defineEmits<{ "value-change": [value: HslaColor | RgbaColor] }>();
const inputValues = computed(() => {
return getInputValues(props.value);
});
function getInputValues(value: HslaColor | RgbaColor): number[] {
const isHsl = props.label === "hsl" || props.label === "hsla";
const hasAlpha = props.label === "hsla" || props.label === "rgba";
if (isHsl) {
const hsl = value as HslaColor;
const values = [Math.round(hsl.h), Math.round(hsl.s), Math.round(hsl.l)];
if (hasAlpha) {
values.push(Math.round(hsl.a * 100));
}
return values;
} else {
const rgb = value as RgbaColor;
const values = [Math.round(rgb.r), Math.round(rgb.g), Math.round(rgb.b)];
if (hasAlpha) {
values.push(Math.round(rgb.a * 100));
}
return values;
}
}
function getInputClass(index: number, totalInputs: number): string {
const baseClass =
"flex-1 w-0 h-8 px-2 py-1 text-xs border border-input bg-background text-center overflow-hidden focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0";
const isFirst = index === 0;
const isLast = index === totalInputs - 1;
const isMiddle = !isFirst && !isLast;
let positionClasses = "";
if (isFirst) {
positionClasses = "rounded-l-md -mr-px";
} else if (isLast) {
positionClasses = "rounded-r-md";
} else if (isMiddle) {
positionClasses = "rounded-none -mr-px";
}
return `${baseClass} ${positionClasses}`;
}
const inputs = computed(() => {
const isHsl = props.label === "hsl" || props.label === "hsla";
const hasAlpha = props.label === "hsla" || props.label === "rgba";
let inputsData;
if (isHsl) {
const hsl = props.value as HslaColor;
inputsData = [
{ key: "h", value: Math.round(hsl.h), min: 0, max: 360, prop: "h" },
{ key: "s", value: Math.round(hsl.s), min: 0, max: 100, prop: "s" },
{ key: "l", value: Math.round(hsl.l), min: 0, max: 100, prop: "l" },
];
if (hasAlpha) {
inputsData.push({ key: "a", value: Math.round(hsl.a * 100), min: 0, max: 100, prop: "a" });
}
} else {
const rgb = props.value as RgbaColor;
inputsData = [
{ key: "r", value: Math.round(rgb.r), min: 0, max: 255, prop: "r" },
{ key: "g", value: Math.round(rgb.g), min: 0, max: 255, prop: "g" },
{ key: "b", value: Math.round(rgb.b), min: 0, max: 255, prop: "b" },
];
if (hasAlpha) {
inputsData.push({ key: "a", value: Math.round(rgb.a * 100), min: 0, max: 100, prop: "a" });
}
}
return inputsData.map((input, index) => ({
...input,
class: getInputClass(index, inputsData.length),
}));
});
function handleChange(idx: number, event: Event) {
const target = event.target as HTMLInputElement;
const value = Number(target.value);
if (Number.isNaN(value)) return;
const input = inputs.value[idx];
const clampedValue = Math.max(input.min, Math.min(input.max, value));
const finalValue = input.prop === "a" ? clampedValue / 100 : clampedValue;
emit("value-change", { ...props.value, [input.prop]: finalValue } as HslaColor | RgbaColor);
}
</script>
<template>
<div class="flex w-full min-w-0">
<input
v-for="(input, idx) in inputs"
:key="input.key"
:value="inputValues[idx]"
:min="input.min"
:max="input.max"
:class="input.class"
step="1"
@input="handleChange(idx, $event)"
/>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
export { default as ColorPicker } from "./ColorPicker.vue";
export type { ColorPickerProps, ColorPickerValue } from "./ColorPicker.vue";
export { default as ContrastRatio } from "./ContrastRatio.vue";
export { default as ObjectColorInput } from "./ObjectColorInput.vue";
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { ListboxRootEmits, ListboxRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ListboxRoot, useFilter, useForwardPropsEmits } from 'reka-ui'
import { reactive, ref, watch } from 'vue'
import { cn } from '@/lib/utils'
import { provideCommandContext } from '.'
const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes['class'] }>(), {
modelValue: '',
highlightOnHover: true,
})
const emits = defineEmits<ListboxRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const allItems = ref<Map<string, string>>(new Map())
const allGroups = ref<Map<string, Set<string>>>(new Map())
const { contains } = useFilter({ sensitivity: 'base' })
const filterState = reactive({
search: '',
filtered: {
/** The count of all visible items. */
count: 0,
/** Map from visible item id to its search score. */
items: new Map() as Map<string, number>,
/** Set of groups with at least one visible item. */
groups: new Set() as Set<string>,
},
})
function filterItems() {
if (!filterState.search) {
filterState.filtered.count = allItems.value.size
// Do nothing, each item will know to show itself because search is empty
return
}
// Reset the groups
filterState.filtered.groups = new Set()
let itemCount = 0
// Check which items should be included
for (const [id, value] of allItems.value) {
const score = contains(value, filterState.search)
filterState.filtered.items.set(id, score ? 1 : 0)
if (score)
itemCount++
}
// Check which groups have at least 1 item shown
for (const [groupId, group] of allGroups.value) {
for (const itemId of group) {
if (filterState.filtered.items.get(itemId)! > 0) {
filterState.filtered.groups.add(groupId)
break
}
}
}
filterState.filtered.count = itemCount
}
watch(() => filterState.search, () => {
filterItems()
})
provideCommandContext({
allItems,
allGroups,
filterState,
})
</script>
<template>
<ListboxRoot
data-slot="command"
v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground rounded-3xl p-1 flex size-full flex-col overflow-hidden', props.class)"
>
<slot />
</ListboxRoot>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import Command from './Command.vue'
const props = withDefaults(defineProps<DialogRootProps & {
title?: string
description?: string
class?: HTMLAttributes['class']
showCloseButton?: boolean
}>(), {
title: 'Command Palette',
description: 'Search for a command to run...',
showCloseButton: false,
})
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<Dialog v-slot="slotProps" v-bind="forwarded">
<DialogContent
:class="cn('rounded-3xl! top-1/3 translate-y-0 overflow-hidden p-0', props.class)"
:show-close-button="showCloseButton"
>
<DialogHeader class="sr-only">
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
<Command>
<slot v-bind="slotProps" />
</Command>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
import { useCommand } from '.'
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const { filterState } = useCommand()
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0,
)
</script>
<template>
<Primitive
v-if="isRender"
data-slot="command-empty"
v-bind="delegatedProps"
:class="cn('py-6 text-center text-sm', props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { ListboxGroupProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ListboxGroup, ListboxGroupLabel, useId } from 'reka-ui'
import { computed, onMounted, onUnmounted } from 'vue'
import { cn } from '@/lib/utils'
import { provideCommandGroupContext, useCommand } from '.'
const props = defineProps<ListboxGroupProps & {
class?: HTMLAttributes['class']
heading?: string
}>()
const delegatedProps = reactiveOmit(props, 'class')
const { allGroups, filterState } = useCommand()
const id = useId()
const isRender = computed(() => !filterState.search ? true : filterState.filtered.groups.has(id))
provideCommandGroupContext({ id })
onMounted(() => {
if (!allGroups.value.has(id))
allGroups.value.set(id, new Set())
})
onUnmounted(() => {
allGroups.value.delete(id)
})
</script>
<template>
<ListboxGroup
v-bind="delegatedProps"
:id="id"
data-slot="command-group"
:class="cn('text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium', props.class)"
:hidden="isRender ? undefined : true"
>
<ListboxGroupLabel v-if="heading" data-slot="command-group-heading" class="">
{{ heading }}
</ListboxGroupLabel>
<slot />
</ListboxGroup>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { ListboxFilterProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ListboxFilter, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { InputGroup, InputGroupAddon } from '@/components/ui/input-group'
import { useCommand } from '.'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ListboxFilterProps & {
class?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
const { filterState } = useCommand()
</script>
<template>
<div
data-slot="command-input-wrapper"
class="p-1 pb-0"
>
<InputGroup class="bg-input/50 h-8!">
<ListboxFilter
v-bind="{ ...forwardedProps, ...$attrs }"
v-model="filterState.search"
data-slot="command-input"
auto-focus
:class="cn('w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
<InputGroupAddon>
<Icon name="ph:magnifying-glass-fill" class="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { ListboxItemEmits, ListboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit, useCurrentElement } from '@vueuse/core'
import { ListboxItem, useForwardPropsEmits, useId } from 'reka-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { useCommand, useCommandGroup } from '.'
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ListboxItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const id = useId()
const { filterState, allItems, allGroups } = useCommand()
const groupContext = useCommandGroup()
const isRender = computed(() => {
if (!filterState.search) {
return true
}
else {
const filteredCurrentItem = filterState.filtered.items.get(id)
// If the filtered items is undefined means not in the all times map yet
// Do the first render to add into the map
if (filteredCurrentItem === undefined) {
return true
}
// Check with filter
return filteredCurrentItem > 0
}
})
const itemRef = ref()
const currentElement = useCurrentElement(itemRef)
onMounted(() => {
if (!(currentElement.value instanceof HTMLElement))
return
// textValue to perform filter
allItems.value.set(id, currentElement.value.textContent ?? (props.value?.toString() ?? ''))
const groupId = groupContext?.id
if (groupId) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set([id]))
}
else {
allGroups.value.get(groupId)?.add(id)
}
}
})
onUnmounted(() => {
allItems.value.delete(id)
})
</script>
<template>
<ListboxItem
v-if="isRender"
v-bind="forwarded"
:id="id"
ref="itemRef"
data-slot="command-item"
:class="cn('data-highlighted:bg-muted data-highlighted:text-foreground data-highlighted:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 min-h-7 rounded-xl px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-2xl [&_svg:not([class*=size-])]:size-4 group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
@select="() => {
filterState.search = ''
}"
>
<slot />
<Icon name="ph:check-fill" class="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</ListboxItem>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { ListboxContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ListboxContent, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ListboxContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ListboxContent
data-slot="command-list"
v-bind="forwarded"
:class="cn('no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto', props.class)"
>
<div role="presentation">
<slot />
</div>
</ListboxContent>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator
data-slot="command-separator"
v-bind="delegatedProps"
:class="cn('bg-border/50 my-1 h-px', props.class)"
>
<slot />
</Separator>
</template>
@@ -0,0 +1,17 @@
<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="command-shortcut"
:class="cn('text-muted-foreground group-data-highlighted/command-item:text-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>
+25
View File
@@ -0,0 +1,25 @@
import type { Ref } from 'vue'
import { createContext } from 'reka-ui'
export { default as Command } from './Command.vue'
export { default as CommandDialog } from './CommandDialog.vue'
export { default as CommandEmpty } from './CommandEmpty.vue'
export { default as CommandGroup } from './CommandGroup.vue'
export { default as CommandInput } from './CommandInput.vue'
export { default as CommandItem } from './CommandItem.vue'
export { default as CommandList } from './CommandList.vue'
export { default as CommandSeparator } from './CommandSeparator.vue'
export { default as CommandShortcut } from './CommandShortcut.vue'
export const [useCommand, provideCommandContext] = createContext<{
allItems: Ref<Map<string, string>>
allGroups: Ref<Map<string, Set<string>>>
filterState: {
search: string
filtered: { count: number, items: Map<string, number>, groups: Set<string> }
}
}>('Command')
export const [useCommandGroup, provideCommandGroupContext] = createContext<{
id?: string
}>('CommandGroup')
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import DialogOverlay from './DialogOverlay.vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class'], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 dark:ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-[min(var(--radius-4xl),24px)] p-6 text-sm shadow-xl ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
as-child
>
<Button variant="ghost" class="absolute top-4 right-4 bg-secondary" size="icon-sm">
<Icon name="ph:x-fill" />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
>
<slot />
</DialogDescription>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { DialogClose } from 'reka-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
showCloseButton?: boolean
}>(), {
showCloseButton: false,
})
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="outline">
Close
</Button>
</DialogClose>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<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="dialog-header"
:class="cn('gap-1.5 flex flex-col', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm fixed inset-0 isolate z-50', props.class)"
>
<slot />
</DialogOverlay>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<Icon name="ph:x-fill" class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-base leading-none font-medium cn-font-heading', props.class)"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>
+10
View File
@@ -0,0 +1,10 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogOverlay } from './DialogOverlay.vue'
export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'
@@ -0,0 +1,19 @@
<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>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
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 focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Icon name="ph:check-fill" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>
@@ -0,0 +1,40 @@
<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'] }>(),
{
align: 'start',
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('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 dark:ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-md p-1 shadow-lg ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-dropdown-menu-content-available-height) w-(--reka-dropdown-menu-trigger-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>
@@ -0,0 +1,15 @@
<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>
@@ -0,0 +1,31 @@
<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 not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm px-2 py-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
>
<slot />
</DropdownMenuItem>
</template>
@@ -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('text-muted-foreground px-2 py-1 text-xs data-inset:pl-7', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>
@@ -0,0 +1,21 @@
<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>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
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 focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Icon name="ph:check-fill" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>
@@ -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/50 -mx-1 my-1 h-px', props.class)"
/>
</template>
@@ -0,0 +1,17 @@
<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 group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>
@@ -0,0 +1,18 @@
<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>
@@ -0,0 +1,27 @@
<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('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 dark:ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-md p-1 shadow-lg ring-1 duration-100 cn-menu-translucent z-50 max-w-(--reka-dropdown-menu-content-available-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden', props.class)"
>
<slot />
</DropdownMenuSubContent>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
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"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm px-2 py-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
<Icon name="ph:caret-right-fill" class="cn-rtl-flip ml-auto" />
</DropdownMenuSubTrigger>
</template>
@@ -0,0 +1,17 @@
<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>
+16
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'
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { FieldVariants } from '.'
import { cn } from '@/lib/utils'
import { fieldVariants } from '.'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
orientation?: FieldVariants['orientation']
}>(), {
orientation: 'vertical',
})
</script>
<template>
<div
role="group"
data-slot="field"
:data-orientation="orientation"
:class="cn(
fieldVariants({ orientation }),
props.class,
)"
>
<slot />
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<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="field-content"
:class="cn(
'gap-1 group/field-content flex flex-1 flex-col leading-snug',
props.class,
)"
>
<slot />
</div>
</template>
@@ -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>
<p
data-slot="field-description"
:class="cn(
'text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance',
'last:mt-0 nth-last-2:-mt-1',
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
props.class,
)"
>
<slot />
</p>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
errors?: Array<string | { message: string | undefined } | undefined>
}>()
const content = computed(() => {
if (!props.errors || props.errors.length === 0)
return null
const uniqueErrors = [
...new Map(
props.errors
.filter(Boolean)
.map((error) => {
const message = typeof error === 'string' ? error : error?.message
return [message, error]
}),
).values(),
]
if (uniqueErrors.length === 1 && uniqueErrors[0]) {
return typeof uniqueErrors[0] === 'string' ? uniqueErrors[0] : uniqueErrors[0].message
}
return uniqueErrors.map(error => typeof error === 'string' ? error : error?.message)
})
</script>
<template>
<div
v-if="$slots.default || content"
role="alert"
data-slot="field-error"
:class="cn('text-destructive text-sm font-normal', props.class)"
>
<slot v-if="$slots.default" />
<template v-else-if="typeof content === 'string'">
{{ content }}
</template>
<ul v-else-if="Array.isArray(content)" class="ml-4 flex list-disc flex-col gap-1">
<li v-for="(error, index) in content" :key="index">
{{ error }}
</li>
</ul>
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<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="field-group"
:class="cn(
'gap-6 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col',
props.class,
)"
>
<slot />
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Label
data-slot="field-label"
:class="cn(
'has-data-checked:bg-input/30 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-2xl has-[>[data-slot=field]]:border *:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
props.class,
)"
>
<slot />
</Label>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
variant?: 'legend' | 'label'
}>(), {
variant: 'legend',
})
</script>
<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="cn('mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base', props.class)"
>
<slot />
</legend>
</template>
@@ -0,0 +1,29 @@
<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>
<div
data-slot="field-separator"
:data-content="!!$slots.default"
:class="cn(
'-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative',
props.class,
)"
>
<Separator class="absolute inset-0 top-1/2" />
<span
v-if="$slots.default"
class="text-muted-foreground px-2 relative mx-auto block w-fit bg-background"
data-slot="field-separator-content"
>
<slot />
</span>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<fieldset
data-slot="field-set"
:class="cn('gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col', props.class)"
>
<slot />
</fieldset>
</template>
+20
View File
@@ -0,0 +1,20 @@
<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="field-label"
:class="cn(
'gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug',
props.class,
)"
>
<slot />
</div>
</template>
+31
View File
@@ -0,0 +1,31 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const fieldVariants = cva('data-[invalid=true]:text-destructive gap-3 group/field flex w-full', {
variants: {
orientation: {
vertical:
'flex-col *:w-full [&>.sr-only]:w-auto',
horizontal:
'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
responsive:
'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
},
},
defaultVariants: {
orientation: 'vertical',
},
})
export type FieldVariants = VariantProps<typeof fieldVariants>
export { default as Field } from './Field.vue'
export { default as FieldContent } from './FieldContent.vue'
export { default as FieldDescription } from './FieldDescription.vue'
export { default as FieldError } from './FieldError.vue'
export { default as FieldGroup } from './FieldGroup.vue'
export { default as FieldLabel } from './FieldLabel.vue'
export { default as FieldLegend } from './FieldLegend.vue'
export { default as FieldSeparator } from './FieldSeparator.vue'
export { default as FieldSet } from './FieldSet.vue'
export { default as FieldTitle } from './FieldTitle.vue'
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<HoverCardRootProps>()
const emits = defineEmits<HoverCardRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<HoverCardRoot
v-slot="slotProps"
data-slot="hover-card"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</HoverCardRoot>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { HoverCardContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
HoverCardContent,
HoverCardPortal,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>(),
{
align: 'center',
sideOffset: 4,
},
)
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<HoverCardPortal>
<HoverCardContent
data-slot="hover-card-content"
v-bind="{ ...$attrs, ...forwardedProps }"
:class="
cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 dark:ring-foreground/10 bg-popover text-popover-foreground w-72 rounded-3xl p-4 text-sm shadow-lg ring-1 duration-100 z-50 origin-(--reka-hover-card-content-transform-origin) outline-hidden',
props.class,
)
"
>
<slot />
</HoverCardContent>
</HoverCardPortal>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HoverCardTriggerProps } from 'reka-ui'
import { HoverCardTrigger } from 'reka-ui'
const props = defineProps<HoverCardTriggerProps>()
</script>
<template>
<HoverCardTrigger
data-slot="hover-card-trigger"
v-bind="props"
>
<slot />
</HoverCardTrigger>
</template>
+3
View File
@@ -0,0 +1,3 @@
export { default as HoverCard } from './HoverCard.vue'
export { default as HoverCardContent } from './HoverCardContent.vue'
export { default as HoverCardTrigger } from './HoverCardTrigger.vue'

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