mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-21 15:31:46 +01:00
Merge branch 'main' of https://github.com/benjamincanac/nuxt-ui3 into dev
This commit is contained in:
108
src/runtime/components/Accordion.vue
Normal file
108
src/runtime/components/Accordion.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { AccordionRootProps, AccordionRootEmits } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/accordion'
|
||||
import type { IconProps } from '#ui/components/Icon.vue'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { accordion: Partial<typeof theme> } }
|
||||
|
||||
const accordion = tv({ extend: tv(theme), ...(appConfig.ui?.accordion || {}) })
|
||||
|
||||
export interface AccordionItem {
|
||||
slot?: string
|
||||
icon?: IconProps['name']
|
||||
label?: string
|
||||
value?: string
|
||||
content?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface AccordionProps<T extends AccordionItem> extends Omit<AccordionRootProps, 'asChild' | 'dir' | 'orientation'> {
|
||||
items?: T[]
|
||||
class?: any
|
||||
ui?: Partial<typeof accordion.slots>
|
||||
}
|
||||
|
||||
export interface AccordionEmits extends AccordionRootEmits {}
|
||||
|
||||
type SlotProps<T> = (props: { item: T, index: number }) => any
|
||||
|
||||
export type AccordionSlots<T extends AccordionItem> = {
|
||||
leading: SlotProps<T>
|
||||
default: SlotProps<T>
|
||||
trailing: SlotProps<T>
|
||||
content: SlotProps<T>
|
||||
} & {
|
||||
[key in T['slot'] as string]?: SlotProps<T>
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends AccordionItem">
|
||||
import { computed } from 'vue'
|
||||
import { AccordionRoot, AccordionItem, AccordionHeader, AccordionTrigger, AccordionContent, useForwardPropsEmits } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import { useAppConfig } from '#app'
|
||||
|
||||
const props = withDefaults(defineProps<AccordionProps<T>>(), {
|
||||
type: 'single',
|
||||
collapsible: true,
|
||||
defaultValue: '0'
|
||||
})
|
||||
const emits = defineEmits<AccordionEmits>()
|
||||
defineSlots<AccordionSlots<T>>()
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'collapsible', 'defaultValue', 'disabled', 'modelValue', 'type'), emits)
|
||||
|
||||
const ui = computed(() => tv({ extend: accordion, slots: props.ui })())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
|
||||
<AccordionItem v-for="(item, index) in items" :key="index" :value="item.value || String(index)" :disabled="item.disabled" :class="ui.item()">
|
||||
<AccordionHeader :class="ui.header()">
|
||||
<AccordionTrigger :class="ui.trigger()">
|
||||
<slot name="leading" :item="item" :index="index">
|
||||
<UIcon v-if="item.icon" :name="item.icon" :class="ui.leadingIcon()" />
|
||||
</slot>
|
||||
|
||||
<span v-if="item.label || $slots.default" :class="ui.label()">
|
||||
<slot :item="item" :index="index">{{ item.label }}</slot>
|
||||
</span>
|
||||
|
||||
<slot name="trailing" :item="item" :index="index">
|
||||
<UIcon :name="appConfig.ui.icons.chevronDown" :class="ui.trailingIcon()" />
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
|
||||
<AccordionContent v-if="item.content || $slots.content || (item.slot && $slots[item.slot])" :class="ui.content()" :value="item.value || String(index)">
|
||||
<slot :name="item.slot || 'content'" :item="item" :index="index">
|
||||
{{ item.content }}
|
||||
</slot>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
src/runtime/components/Avatar.vue
Normal file
51
src/runtime/components/Avatar.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { AvatarFallbackProps, AvatarRootProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/avatar'
|
||||
import type { IconProps } from '#ui/components/Icon.vue'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { avatar: Partial<typeof theme> } }
|
||||
|
||||
const avatar = tv({ extend: tv(theme), ...(appConfig.ui?.avatar || {}) })
|
||||
|
||||
type AvatarVariants = VariantProps<typeof avatar>
|
||||
|
||||
export interface AvatarProps extends Omit<AvatarRootProps, 'asChild'>, Omit<AvatarFallbackProps, 'as' | 'asChild'> {
|
||||
src?: string
|
||||
alt?: string
|
||||
icon?: IconProps['name']
|
||||
text?: string
|
||||
size?: AvatarVariants['size']
|
||||
class?: any
|
||||
ui?: Partial<typeof avatar.slots>
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { AvatarRoot, AvatarImage, AvatarFallback, useForwardProps } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import UIcon from '#ui/components/Icon.vue'
|
||||
|
||||
const props = defineProps<AvatarProps>()
|
||||
|
||||
const rootProps = useForwardProps(reactivePick(props, 'as'))
|
||||
const fallbackProps = useForwardProps(reactivePick(props, 'delayMs'))
|
||||
|
||||
const fallback = computed(() => props.text || (props.alt || '').split(' ').map(word => word.charAt(0)).join('').substring(0, 2))
|
||||
|
||||
const ui = computed(() => tv({ extend: avatar, slots: props.ui })({ size: props.size }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
|
||||
<AvatarImage v-if="src" :src="src" :alt="alt" :class="ui.image()" />
|
||||
|
||||
<AvatarFallback as-child v-bind="fallbackProps">
|
||||
<UIcon v-if="icon" :name="icon" :class="ui.icon()" />
|
||||
<span v-else :class="ui.fallback()">{{ fallback }}</span>
|
||||
</AvatarFallback>
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
40
src/runtime/components/Badge.vue
Normal file
40
src/runtime/components/Badge.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { PrimitiveProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/badge'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { badge: Partial<typeof theme> } }
|
||||
|
||||
const badge = tv({ extend: tv(theme), ...(appConfig.ui?.badge || {}) })
|
||||
|
||||
type BadgeVariants = VariantProps<typeof badge>
|
||||
|
||||
export interface BadgeProps extends Omit<PrimitiveProps, 'asChild'> {
|
||||
label?: string | number
|
||||
color?: BadgeVariants['color']
|
||||
variant?: BadgeVariants['variant']
|
||||
size?: BadgeVariants['size']
|
||||
class?: any
|
||||
}
|
||||
|
||||
export interface BadgeSlots {
|
||||
default(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from 'radix-vue'
|
||||
|
||||
const props = withDefaults(defineProps<BadgeProps>(), { as: 'span' })
|
||||
defineSlots<BadgeSlots>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :class="badge({ color, variant, size, class: props.class })">
|
||||
<slot>
|
||||
{{ label }}
|
||||
</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
78
src/runtime/components/Button.vue
Normal file
78
src/runtime/components/Button.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/button'
|
||||
import type { LinkProps } from '#ui/components/Link.vue'
|
||||
import type { UseComponentIconsProps } from '#ui/composables/useComponentIcons'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { button: Partial<typeof theme> } }
|
||||
|
||||
const button = tv({ extend: tv(theme), ...(appConfig.ui?.button || {}) })
|
||||
|
||||
type ButtonVariants = VariantProps<typeof button>
|
||||
|
||||
export interface ButtonProps extends UseComponentIconsProps, LinkProps {
|
||||
label?: string
|
||||
color?: ButtonVariants['color']
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
square?: boolean
|
||||
block?: boolean
|
||||
truncate?: boolean
|
||||
class?: any
|
||||
ui?: Partial<typeof button.slots>
|
||||
}
|
||||
|
||||
export interface ButtonSlots {
|
||||
leading(): any
|
||||
default(): any
|
||||
trailing(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useForwardProps } from 'radix-vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import UIcon from '#ui/components/Icon.vue'
|
||||
import { useComponentIcons } from '#ui/composables/useComponentIcons'
|
||||
|
||||
const props = defineProps<ButtonProps>()
|
||||
const slots = defineSlots<ButtonSlots>()
|
||||
|
||||
const linkProps = useForwardProps(reactiveOmit(props, 'type', 'label', 'color', 'variant', 'size', 'icon', 'leading', 'leadingIcon', 'trailing', 'trailingIcon', 'loading', 'loadingIcon', 'square', 'block', 'disabled', 'truncate', 'class', 'ui'))
|
||||
|
||||
// const { size, rounded } = useInjectButtonGroup({ ui, props })
|
||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
||||
|
||||
const ui = computed(() => tv({ extend: button, slots: props.ui })({
|
||||
color: props.color,
|
||||
variant: props.variant,
|
||||
size: props.size,
|
||||
loading: props.loading,
|
||||
truncate: props.truncate,
|
||||
block: props.block,
|
||||
square: props.square || (!slots.default && !props.label),
|
||||
leading: isLeading.value,
|
||||
trailing: isTrailing.value
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ULink :type="type" :disabled="disabled || loading" :class="ui.base({ class: props.class })" v-bind="linkProps" raw>
|
||||
<slot name="leading">
|
||||
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon()" aria-hidden="true" />
|
||||
</slot>
|
||||
|
||||
<span v-if="label || $slots.default" :class="ui.label()">
|
||||
<slot>
|
||||
{{ label }}
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<slot name="trailing">
|
||||
<UIcon v-if="isTrailing && trailingIconName" :name="trailingIconName" :class="ui.trailingIcon()" aria-hidden="true" />
|
||||
</slot>
|
||||
</ULink>
|
||||
</template>
|
||||
41
src/runtime/components/Card.vue
Normal file
41
src/runtime/components/Card.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { PrimitiveProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/card'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { card: Partial<typeof theme> } }
|
||||
|
||||
const card = tv({ extend: tv(theme), ...(appConfig.ui?.card || {}) })
|
||||
|
||||
export interface CardProps extends Omit<PrimitiveProps, 'asChild'> {
|
||||
class?: any
|
||||
ui?: Partial<typeof card.slots>
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Primitive } from 'radix-vue'
|
||||
|
||||
const props = withDefaults(defineProps<CardProps>(), { as: 'div' })
|
||||
|
||||
const ui = computed(() => tv({ extend: card, slots: props.ui })())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :class="ui.root({ class: props.class })">
|
||||
<div v-if="$slots.header" :class="ui.header()">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.default" :class="ui.body()">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" :class="ui.footer()">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</Primitive>
|
||||
</template>
|
||||
117
src/runtime/components/Checkbox.vue
Normal file
117
src/runtime/components/Checkbox.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { CheckboxRootProps, CheckboxRootEmits } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/checkbox'
|
||||
import type { IconProps } from '#ui/components/Icon.vue'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { checkbox: Partial<typeof theme> } }
|
||||
|
||||
const checkbox = tv({ extend: tv(theme), ...(appConfig.ui?.checkbox || {}) })
|
||||
|
||||
type CheckboxVariants = VariantProps<typeof checkbox>
|
||||
|
||||
export interface CheckboxProps extends Omit<CheckboxRootProps, 'asChild'> {
|
||||
id?: string
|
||||
name?: string
|
||||
description?: string
|
||||
label?: string
|
||||
color?: CheckboxVariants['color']
|
||||
size?: CheckboxVariants['size']
|
||||
icon?: IconProps['name']
|
||||
indeterminateIcon?: IconProps['name']
|
||||
indeterminate?: boolean
|
||||
class?: any
|
||||
ui?: Partial<typeof checkbox.slots>
|
||||
}
|
||||
|
||||
export interface CheckboxEmits extends CheckboxRootEmits {}
|
||||
|
||||
export interface CheckboxSlots {
|
||||
label(props: { label?: string }): any
|
||||
description(props: { description?: string }): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { CheckboxRoot, CheckboxIndicator, Label, useForwardPropsEmits } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import { useId } from '#imports'
|
||||
import { useFormField } from '#ui/composables/useFormField'
|
||||
import { useAppConfig } from '#app'
|
||||
|
||||
const props = defineProps<CheckboxProps>()
|
||||
const emits = defineEmits<CheckboxEmits>()
|
||||
defineSlots<CheckboxSlots>()
|
||||
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultChecked', 'disabled', 'required', 'name'), emits)
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
const { inputId: _inputId, emitFormChange, size, color, name, disabled } = useFormField<CheckboxProps>(props)
|
||||
const inputId = _inputId.value ?? useId()
|
||||
|
||||
const modelValue = defineModel<boolean | undefined>({
|
||||
default: undefined,
|
||||
set (value) {
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
const indeterminate = computed(() => (modelValue.value === undefined && props.indeterminate))
|
||||
|
||||
const checked = computed({
|
||||
get () {
|
||||
return indeterminate.value ? 'indeterminate' : modelValue.value
|
||||
},
|
||||
set (value) {
|
||||
modelValue.value = value === 'indeterminate' ? undefined : value
|
||||
}
|
||||
})
|
||||
|
||||
function onChecked () {
|
||||
emitFormChange()
|
||||
}
|
||||
|
||||
const ui = computed(() => tv({ extend: checkbox, slots: props.ui })({
|
||||
size: size.value,
|
||||
color: color.value,
|
||||
required: props.required,
|
||||
disabled: disabled.value,
|
||||
checked: modelValue.value ?? props.defaultChecked,
|
||||
indeterminate: indeterminate.value
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="ui.root({ class: props.class })">
|
||||
<div :class="ui.container()">
|
||||
<CheckboxRoot
|
||||
:id="inputId"
|
||||
v-model:checked="checked"
|
||||
v-bind="{ ...rootProps, name, disabled }"
|
||||
:class="ui.base()"
|
||||
@update:checked="onChecked"
|
||||
>
|
||||
<CheckboxIndicator :class="ui.indicator()">
|
||||
<UIcon v-if="indeterminate" :name="indeterminateIcon || appConfig.ui.icons.minus" :class="ui.icon()" />
|
||||
<UIcon v-else :name="icon || appConfig.ui.icons.check" :class="ui.icon()" />
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
|
||||
<div v-if="(label || $slots.label) || (description || $slots.description)" :class="ui.wrapper()">
|
||||
<Label v-if="label || $slots.label" :for="inputId" :class="ui.label()">
|
||||
<slot name="label" :label="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</Label>
|
||||
<p v-if="description || $slots.description" :class="ui.description()">
|
||||
<slot name="description" :description="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
56
src/runtime/components/Chip.vue
Normal file
56
src/runtime/components/Chip.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { PrimitiveProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/chip'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { chip: Partial<typeof theme> } }
|
||||
|
||||
const chip = tv({ extend: tv(theme), ...(appConfig.ui?.chip || {}) })
|
||||
|
||||
type ChipVariants = VariantProps<typeof chip>
|
||||
|
||||
export interface ChipProps extends Omit<PrimitiveProps, 'asChild'> {
|
||||
text?: string | number
|
||||
inset?: boolean
|
||||
color?: ChipVariants['color']
|
||||
size?: ChipVariants['size']
|
||||
position?: ChipVariants['position']
|
||||
class?: any
|
||||
ui?: Partial<typeof theme.slots>
|
||||
}
|
||||
|
||||
export interface ChipSlots {
|
||||
default(): any
|
||||
content(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Primitive } from 'radix-vue'
|
||||
|
||||
const show = defineModel<boolean>('show', { default: true })
|
||||
const props = withDefaults(defineProps<ChipProps>(), { as: 'div' })
|
||||
defineSlots<ChipSlots>()
|
||||
|
||||
const ui = computed(() => tv({ extend: chip, slots: props.ui })({
|
||||
color: props.color,
|
||||
size: props.size,
|
||||
position: props.position,
|
||||
inset: props.inset
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :class="ui.root({ class: props.class })">
|
||||
<slot />
|
||||
|
||||
<span v-if="show" :class="ui.base()">
|
||||
<slot name="content">
|
||||
{{ text }}
|
||||
</slot>
|
||||
</span>
|
||||
</Primitive>
|
||||
</template>
|
||||
69
src/runtime/components/Collapsible.vue
Normal file
69
src/runtime/components/Collapsible.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { CollapsibleRootProps, CollapsibleRootEmits } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/collapsible'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { collapsible: Partial<typeof theme> } }
|
||||
|
||||
const collapsible = tv({ extend: tv(theme), ...(appConfig.ui?.collapsible || {}) })
|
||||
|
||||
export interface CollapsibleProps extends Omit<CollapsibleRootProps, 'asChild'> {
|
||||
class?: any
|
||||
ui?: Partial<typeof collapsible.slots>
|
||||
}
|
||||
|
||||
export interface CollapsibleEmits extends CollapsibleRootEmits {}
|
||||
|
||||
export interface CollapsibleSlots {
|
||||
default(): any
|
||||
content(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, useForwardPropsEmits } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<CollapsibleProps>()
|
||||
const emits = defineEmits<CollapsibleEmits>()
|
||||
defineSlots<CollapsibleSlots>()
|
||||
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultOpen', 'open', 'disabled'), emits)
|
||||
|
||||
const ui = computed(() => tv({ extend: collapsible, slots: props.ui })())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
|
||||
<CollapsibleTrigger v-if="$slots.default" as-child>
|
||||
<slot />
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent :class="ui.content()">
|
||||
<slot name="content" />
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes collapsible-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes collapsible-up {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
src/runtime/components/Container.vue
Normal file
27
src/runtime/components/Container.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { PrimitiveProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/container'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { container: Partial<typeof theme> } }
|
||||
|
||||
const container = tv({ extend: tv(theme), ...(appConfig.ui?.container || {}) })
|
||||
|
||||
export interface ContainerProps extends Omit<PrimitiveProps, 'asChild'> {
|
||||
class?: any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from 'radix-vue'
|
||||
|
||||
const props = withDefaults(defineProps<ContainerProps>(), { as: 'div' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :class="container({ class: props.class })">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
247
src/runtime/components/Form.vue
Normal file
247
src/runtime/components/Form.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/form'
|
||||
import { getYupErrors, isYupSchema, getValibotError, isValibotSchema, getZodErrors, isZodSchema, getJoiErrors, isJoiSchema } from '#ui/utils/form'
|
||||
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, FormInjectedOptions, Form, FormErrorWithId } from '#ui/types/form'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { form: Partial<typeof theme> } }
|
||||
|
||||
const form = tv({ extend: tv(theme), ...(appConfig.ui?.form || {}) })
|
||||
|
||||
export interface FormProps<T extends object> {
|
||||
id?: string | number
|
||||
schema?: FormSchema<T>
|
||||
state: Partial<T>
|
||||
validate?: (state: Partial<T>) => Promise<FormError[] | void>
|
||||
validateOn?: FormInputEvents[]
|
||||
disabled?: boolean
|
||||
validateOnInputDelay?: number
|
||||
class?: any
|
||||
}
|
||||
|
||||
export interface FormEmits<T extends object> {
|
||||
(e: 'submit', payload: FormSubmitEvent<T>): void
|
||||
(e: 'error', payload: FormErrorEvent): void
|
||||
}
|
||||
|
||||
export interface FormSlots {
|
||||
default(): any
|
||||
}
|
||||
|
||||
export class FormValidationException extends Error {
|
||||
formId: string | number
|
||||
errors: FormErrorWithId[]
|
||||
childrens: FormValidationException[]
|
||||
|
||||
constructor (formId: string | number, errors: FormErrorWithId[], childErrors: FormValidationException[]) {
|
||||
super('Form validation exception')
|
||||
this.formId = formId
|
||||
this.errors = errors
|
||||
this.childrens = childErrors
|
||||
Object.setPrototypeOf(this, FormValidationException.prototype)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup generic="T extends object">
|
||||
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed } from 'vue'
|
||||
import { useEventBus, type UseEventBusReturn } from '@vueuse/core'
|
||||
import { useId } from '#imports'
|
||||
|
||||
const props = withDefaults(defineProps<FormProps<T>>(), {
|
||||
validateOn () {
|
||||
return ['input', 'blur', 'change'] as FormInputEvents[]
|
||||
},
|
||||
validateOnInputDelay: 300
|
||||
})
|
||||
const emit = defineEmits<FormEmits<T>>()
|
||||
defineSlots<FormSlots>()
|
||||
|
||||
const formId = props.id ?? useId()
|
||||
|
||||
const bus = useEventBus<FormEvent>(`form-${formId}`)
|
||||
const parentBus = inject<UseEventBusReturn<FormEvent, string> | undefined>(
|
||||
'form-events',
|
||||
undefined
|
||||
)
|
||||
provide('form-events', bus)
|
||||
|
||||
|
||||
const nestedForms = ref<Map<string | number, { validate: () => any }>>(new Map())
|
||||
|
||||
onMounted(async () => {
|
||||
bus.on(async (event) => {
|
||||
if (event.type === 'attach') {
|
||||
nestedForms.value.set(event.formId, { validate: event.validate })
|
||||
} else if (event.type === 'detach') {
|
||||
nestedForms.value.delete(event.formId)
|
||||
} else if (props.validateOn?.includes(event.type as FormInputEvents)) {
|
||||
await _validate({ name: event.name, silent: true, nested: false })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
bus.reset()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (parentBus) {
|
||||
await nextTick()
|
||||
parentBus.emit({ type: 'attach', validate: _validate, formId })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (parentBus) {
|
||||
parentBus.emit({ type: 'detach', formId })
|
||||
}
|
||||
})
|
||||
|
||||
const options = {
|
||||
disabled: computed(() => props.disabled),
|
||||
validateOnInputDelay: computed(() => props.validateOnInputDelay)
|
||||
}
|
||||
provide<FormInjectedOptions>('form-options', options)
|
||||
|
||||
const errors = ref<FormErrorWithId[]>([])
|
||||
provide('form-errors', errors)
|
||||
|
||||
const inputs = ref<Record<string, string>>({})
|
||||
provide('form-inputs', inputs)
|
||||
function resolveErrorIds (errs: FormError[]): FormErrorWithId[] {
|
||||
return errs.map((err) => ({
|
||||
...err,
|
||||
id: inputs.value[err.name]
|
||||
}))
|
||||
}
|
||||
|
||||
async function getErrors (): Promise<FormErrorWithId[]> {
|
||||
let errs = props.validate ? (await props.validate(props.state)) ?? [] : []
|
||||
|
||||
if (props.schema) {
|
||||
if (isZodSchema(props.schema)) {
|
||||
errs = errs.concat(await getZodErrors(props.state, props.schema))
|
||||
} else if (isYupSchema(props.schema)) {
|
||||
errs = errs.concat(await getYupErrors(props.state, props.schema))
|
||||
} else if (isJoiSchema(props.schema)) {
|
||||
errs = errs.concat(await getJoiErrors(props.state, props.schema))
|
||||
} else if (isValibotSchema(props.schema)) {
|
||||
errs = errs.concat(await getValibotError(props.state, props.schema))
|
||||
} else {
|
||||
throw new Error('Form validation failed: Unsupported form schema')
|
||||
}
|
||||
}
|
||||
|
||||
return resolveErrorIds(errs)
|
||||
}
|
||||
|
||||
async function _validate (
|
||||
opts: { name?: string | string[], silent?: boolean, nested?: boolean } = { silent: false, nested: true }
|
||||
): Promise<T | false> {
|
||||
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name
|
||||
|
||||
const nestedValidatePromises = !names && opts.nested ? Array.from(nestedForms.value.values()).map(
|
||||
({ validate }) => validate().then(() => undefined).catch((error: Error) => {
|
||||
if (!(error instanceof FormValidationException)) {
|
||||
throw error
|
||||
}
|
||||
return error
|
||||
})
|
||||
) : []
|
||||
|
||||
if (names) {
|
||||
const otherErrors = errors.value.filter(
|
||||
(error) => !names!.includes(error.name)
|
||||
)
|
||||
const pathErrors = (await getErrors()).filter((error) =>
|
||||
names!.includes(error.name)
|
||||
)
|
||||
errors.value = otherErrors.concat(pathErrors)
|
||||
} else {
|
||||
errors.value = await getErrors()
|
||||
}
|
||||
|
||||
const childErrors = nestedValidatePromises ? await Promise.all(nestedValidatePromises) : []
|
||||
if (errors.value.length + childErrors.length > 0) {
|
||||
if (opts.silent) return false
|
||||
throw new FormValidationException(formId, errors.value, childErrors)
|
||||
}
|
||||
|
||||
return props.state as T
|
||||
}
|
||||
|
||||
async function onSubmit (payload: Event) {
|
||||
const event = payload as SubmitEvent
|
||||
|
||||
try {
|
||||
await _validate({ nested: true })
|
||||
const submitEvent: FormSubmitEvent<any> = {
|
||||
...event,
|
||||
data: props.state
|
||||
}
|
||||
emit('submit', submitEvent)
|
||||
|
||||
} catch (error) {
|
||||
if (!(error instanceof FormValidationException)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const errorEvent: FormErrorEvent = {
|
||||
...event,
|
||||
errors: error.errors,
|
||||
childrens: error.childrens
|
||||
}
|
||||
|
||||
emit('error', errorEvent)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose<Form<T>>({
|
||||
validate: _validate,
|
||||
errors,
|
||||
|
||||
setErrors (errs: FormError[], name?: string) {
|
||||
if (name) {
|
||||
errors.value = errors.value
|
||||
.filter((error) => error.name !== name)
|
||||
.concat(resolveErrorIds(errs))
|
||||
} else {
|
||||
errors.value = resolveErrorIds(errs)
|
||||
}
|
||||
},
|
||||
|
||||
async submit () {
|
||||
await onSubmit(new Event('submit'))
|
||||
},
|
||||
|
||||
getErrors (name?: string) {
|
||||
if (name) {
|
||||
return errors.value.filter((err) => err.name === name)
|
||||
}
|
||||
return errors.value
|
||||
},
|
||||
|
||||
clear (name?: string) {
|
||||
if (name) {
|
||||
errors.value = errors.value.filter((err) => err.name !== name)
|
||||
} else {
|
||||
errors.value = []
|
||||
}
|
||||
},
|
||||
...options
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="parentBus ? 'div' : 'form'"
|
||||
:id="formId"
|
||||
:class="form({ class: props.class })"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
111
src/runtime/components/FormField.vue
Normal file
111
src/runtime/components/FormField.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/formField'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { formField: Partial<typeof theme> } }
|
||||
|
||||
const formField = tv({ extend: tv(theme), ...(appConfig.ui?.formField || {}) })
|
||||
|
||||
type FormFieldVariants = VariantProps<typeof formField>
|
||||
|
||||
export interface FormFieldProps {
|
||||
name?: string
|
||||
label?: string
|
||||
description?: string
|
||||
help?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
size?: FormFieldVariants['size']
|
||||
required?: boolean
|
||||
eagerValidation?: boolean
|
||||
validateOnInputDelay?: number
|
||||
class?: any
|
||||
ui?: Partial<typeof formField.slots>
|
||||
}
|
||||
|
||||
export interface FormFieldSlots {
|
||||
label(props: { label?: string }): any
|
||||
hint(props: { hint?: string }): any
|
||||
description(props: { description?: string }): any
|
||||
error(props: { error?: string }): any
|
||||
help(props: { help?: string }): any
|
||||
default(props: { error?: string }): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, inject, provide, type Ref } from 'vue'
|
||||
import { Label } from 'radix-vue'
|
||||
import type { FormError, FormFieldInjectedOptions } from '#ui/types/form'
|
||||
import { useId } from '#imports'
|
||||
|
||||
const props = defineProps<FormFieldProps>()
|
||||
defineSlots<FormFieldSlots>()
|
||||
|
||||
const ui = computed(() => tv({ extend: formField, slots: props.ui })({
|
||||
size: props.size,
|
||||
required: props.required
|
||||
}))
|
||||
|
||||
const formErrors = inject<Ref<FormError[]> | null>('form-errors', null)
|
||||
|
||||
const error = computed(() => {
|
||||
return (props.error && typeof props.error === 'string') ||
|
||||
typeof props.error === 'boolean'
|
||||
? props.error
|
||||
: formErrors?.value?.find((error) => error.name === props.name)?.message
|
||||
})
|
||||
|
||||
const inputId = ref(useId())
|
||||
|
||||
provide<FormFieldInjectedOptions<FormFieldProps>>('form-field', {
|
||||
error,
|
||||
inputId,
|
||||
name: computed(() => props.name),
|
||||
size: computed(() => props.size),
|
||||
eagerValidation: computed(() => props.eagerValidation),
|
||||
validateOnInputDelay: computed(() => props.validateOnInputDelay)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="ui.root({ class: props.class })">
|
||||
<div :class="ui.wrapper()">
|
||||
<div v-if="label || $slots.label" :class="ui.labelWrapper()">
|
||||
<Label :for="inputId" :class="ui.label()">
|
||||
<slot name="label" :label="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</Label>
|
||||
<span v-if="hint || $slots.hint" :class="ui.hint()">
|
||||
<slot name="hint" :hint="hint">
|
||||
{{ hint }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="description || $slots.description" :class="ui.description()">
|
||||
<slot name="description" :description="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :class="label ? ui.container() : ''">
|
||||
<slot :error="error" />
|
||||
|
||||
<p v-if="(typeof error === 'string' && error) || $slots.error" :class="ui.error()">
|
||||
<slot name="error" :error="error">
|
||||
{{ error }}
|
||||
</slot>
|
||||
</p>
|
||||
<p v-else-if="help || $slots.help" :class="ui.help()">
|
||||
<slot name="help" :help="help">
|
||||
{{ help }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
13
src/runtime/components/Icon.vue
Normal file
13
src/runtime/components/Icon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
export interface IconProps {
|
||||
name: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<IconProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon :name="name" />
|
||||
</template>
|
||||
159
src/runtime/components/Input.vue
Normal file
159
src/runtime/components/Input.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import type { InputHTMLAttributes } from 'vue'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/input'
|
||||
import { looseToNumber } from '#ui/utils'
|
||||
import type { UseComponentIconsProps } from '#ui/composables/useComponentIcons'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { input: Partial<typeof theme> } }
|
||||
|
||||
const input = tv({ extend: tv(theme), ...(appConfig.ui?.input || {}) })
|
||||
|
||||
type InputVariants = VariantProps<typeof input>
|
||||
|
||||
export interface InputProps extends UseComponentIconsProps {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: InputHTMLAttributes['type']
|
||||
placeholder?: string
|
||||
color?: InputVariants['color']
|
||||
variant?: InputVariants['variant']
|
||||
size?: InputVariants['size']
|
||||
required?: boolean
|
||||
autofocus?: boolean
|
||||
autofocusDelay?: number
|
||||
disabled?: boolean
|
||||
class?: any
|
||||
ui?: Partial<typeof input.slots>
|
||||
}
|
||||
|
||||
export interface InputEmits {
|
||||
(e: 'blur', event: FocusEvent): void
|
||||
}
|
||||
|
||||
export interface InputSlots {
|
||||
leading(): any
|
||||
default(): any
|
||||
trailing(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useFormField } from '#ui/composables/useFormField'
|
||||
import { useComponentIcons } from '#ui/composables/useComponentIcons'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<InputProps>(), {
|
||||
type: 'text',
|
||||
autofocusDelay: 100
|
||||
})
|
||||
|
||||
const [modelValue, modelModifiers] = defineModel<string | number>()
|
||||
|
||||
const emit = defineEmits<InputEmits>()
|
||||
defineSlots<InputSlots>()
|
||||
|
||||
const { emitFormBlur, emitFormInput, size, color, inputId, name, disabled } = useFormField<InputProps>(props)
|
||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
||||
// const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props })
|
||||
|
||||
// const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value)
|
||||
|
||||
const ui = computed(() => tv({ extend: input, slots: props.ui })({
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: size?.value,
|
||||
loading: props.loading,
|
||||
leading: isLeading.value,
|
||||
trailing: isTrailing.value
|
||||
}))
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function autoFocus () {
|
||||
if (props.autofocus) {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Custom function to handle the v-model properties
|
||||
function updateInput (value: string) {
|
||||
if (modelModifiers.trim) {
|
||||
value = value.trim()
|
||||
}
|
||||
|
||||
if (modelModifiers.number || props.type === 'number') {
|
||||
value = looseToNumber(value)
|
||||
}
|
||||
|
||||
modelValue.value = value
|
||||
emitFormInput()
|
||||
}
|
||||
|
||||
function onInput (event: Event) {
|
||||
if (!modelModifiers.lazy) {
|
||||
updateInput((event.target as HTMLInputElement).value)
|
||||
}
|
||||
}
|
||||
|
||||
function onChange (event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
|
||||
if (modelModifiers.lazy) {
|
||||
updateInput(value)
|
||||
}
|
||||
|
||||
// Update trimmed input so that it has same behavior as native input https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
|
||||
if (modelModifiers.trim) {
|
||||
(event.target as HTMLInputElement).value = value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur (event: FocusEvent) {
|
||||
emitFormBlur()
|
||||
emit('blur', event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="ui.root({ class: props.class })">
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
:class="ui.base()"
|
||||
:disabled="disabled"
|
||||
v-bind="$attrs"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@change="onChange"
|
||||
>
|
||||
|
||||
<slot />
|
||||
|
||||
<span v-if="(isLeading && leadingIconName) || $slots.leading" :class="ui.leading()">
|
||||
<slot name="leading">
|
||||
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon()" />
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<span v-if="(isTrailing && trailingIconName) || $slots.trailing" :class="ui.trailing()">
|
||||
<slot name="trailing">
|
||||
<UIcon v-if="isTrailing && trailingIconName" :name="trailingIconName" :class="ui.trailingIcon()" />
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
38
src/runtime/components/Kbd.vue
Normal file
38
src/runtime/components/Kbd.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { PrimitiveProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/kbd'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { kbd: Partial<typeof theme> } }
|
||||
|
||||
const kbd = tv({ extend: tv(theme), ...(appConfig.ui?.kbd || {}) })
|
||||
|
||||
type KbdVariants = VariantProps<typeof kbd>
|
||||
|
||||
export interface KbdProps extends Omit<PrimitiveProps, 'asChild'> {
|
||||
value?: string
|
||||
size?: KbdVariants['size']
|
||||
class?: any
|
||||
}
|
||||
|
||||
export interface KbdSlots {
|
||||
default(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from 'radix-vue'
|
||||
|
||||
const props = withDefaults(defineProps<KbdProps>(), { as: 'kbd' })
|
||||
defineSlots<KbdSlots>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :class="kbd({ size, class: props.class })">
|
||||
<slot>
|
||||
{{ value }}
|
||||
</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
101
src/runtime/components/Link.vue
Normal file
101
src/runtime/components/Link.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import type { ButtonHTMLAttributes } from 'vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { PrimitiveProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/link'
|
||||
import type { NuxtLinkProps } from '#app'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { link: Partial<typeof theme> } }
|
||||
|
||||
const link = tv({ extend: tv(theme), ...(appConfig.ui?.link || {}) })
|
||||
|
||||
export interface LinkProps extends NuxtLinkProps, Omit<PrimitiveProps, 'asChild'> {
|
||||
type?: ButtonHTMLAttributes['type']
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
exact?: boolean
|
||||
exactQuery?: boolean
|
||||
exactHash?: boolean
|
||||
inactiveClass?: string
|
||||
custom?: boolean
|
||||
raw?: boolean
|
||||
class?: any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { isEqual } from 'ohash'
|
||||
import { useForwardProps } from 'radix-vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { useRoute } from '#imports'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<LinkProps>(), {
|
||||
as: 'button',
|
||||
type: 'button',
|
||||
active: undefined,
|
||||
activeClass: '',
|
||||
inactiveClass: ''
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const nuxtLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'exactQuery', 'exactHash', 'activeClass', 'inactiveClass'))
|
||||
|
||||
const ui = computed(() => tv({
|
||||
extend: link,
|
||||
variants: {
|
||||
active: {
|
||||
true: props.activeClass,
|
||||
false: props.inactiveClass
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
function isLinkActive (slotProps: any) {
|
||||
if (props.active !== undefined) {
|
||||
return props.active
|
||||
}
|
||||
|
||||
if (props.exactQuery && !isEqual(slotProps.route.query, route.query)) {
|
||||
return false
|
||||
}
|
||||
if (props.exactHash && slotProps.route.hash !== route.hash) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (props.exact && slotProps.isExactActive) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!props.exact && slotProps.isActive) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveLinkClass (slotProps: any) {
|
||||
const active = isLinkActive(slotProps)
|
||||
|
||||
if (props.raw) {
|
||||
return [props.class, active ? props.activeClass : props.inactiveClass]
|
||||
}
|
||||
|
||||
return ui.value({ class: props.class, active, disabled: props.disabled })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink v-slot="slotProps" v-bind="nuxtLinkProps" custom>
|
||||
<template v-if="custom">
|
||||
<slot v-bind="{ ...$attrs, ...slotProps, as, type, disabled, active: isLinkActive(slotProps) }" />
|
||||
</template>
|
||||
<ULinkBase v-else v-bind="{ ...$attrs, ...slotProps, as, type, disabled }" :class="resolveLinkClass(slotProps)">
|
||||
<slot v-bind="{ ...slotProps, as, type, disabled, active: isLinkActive(slotProps) }" />
|
||||
</ULinkBase>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
54
src/runtime/components/LinkBase.vue
Normal file
54
src/runtime/components/LinkBase.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from 'radix-vue'
|
||||
|
||||
const props = defineProps<{
|
||||
as: string
|
||||
type: string
|
||||
disabled?: boolean
|
||||
click?: (e: MouseEvent) => void
|
||||
href?: string
|
||||
navigate: (e: MouseEvent) => void
|
||||
route?: object
|
||||
rel?: string
|
||||
target?: string
|
||||
isExternal?: boolean
|
||||
isActive: boolean
|
||||
isExactActive: boolean
|
||||
}>()
|
||||
|
||||
function onClick (e: MouseEvent) {
|
||||
if (props.disabled) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (props.click) {
|
||||
props.click(e)
|
||||
}
|
||||
|
||||
if (props.href && !props.isExternal) {
|
||||
props.navigate(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-bind="href ? {
|
||||
as: 'a',
|
||||
href: disabled ? undefined : href,
|
||||
'aria-disabled': disabled ? 'true' : undefined,
|
||||
role: disabled ? 'link' : undefined
|
||||
} : {
|
||||
as,
|
||||
type,
|
||||
disabled
|
||||
}"
|
||||
:rel="rel"
|
||||
:target="target"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
169
src/runtime/components/Modal.vue
Normal file
169
src/runtime/components/Modal.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { DialogRootProps, DialogRootEmits, DialogContentProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/modal'
|
||||
import type { ButtonProps } from '#ui/components/Button.vue'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { modal: Partial<typeof theme> } }
|
||||
|
||||
const modal = tv({ extend: tv(theme), ...(appConfig.ui?.modal || {}) })
|
||||
|
||||
export interface ModalProps extends DialogRootProps {
|
||||
title?: string
|
||||
description?: string
|
||||
content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'>
|
||||
overlay?: boolean
|
||||
transition?: boolean
|
||||
fullscreen?: boolean
|
||||
preventClose?: boolean
|
||||
portal?: boolean
|
||||
close?: ButtonProps | null
|
||||
class?: any
|
||||
ui?: Partial<typeof modal.slots>
|
||||
}
|
||||
|
||||
export interface ModalEmits extends DialogRootEmits {}
|
||||
|
||||
export interface ModalSlots {
|
||||
default(): any
|
||||
content(): any
|
||||
header(): any
|
||||
title(): any
|
||||
description(): any
|
||||
close(): any
|
||||
body(): any
|
||||
footer(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose, useForwardPropsEmits } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import { useAppConfig } from '#app'
|
||||
import UButton from '#ui/components/Button.vue'
|
||||
|
||||
const props = withDefaults(defineProps<ModalProps>(), {
|
||||
portal: true,
|
||||
overlay: true,
|
||||
transition: true
|
||||
})
|
||||
const emits = defineEmits<ModalEmits>()
|
||||
defineSlots<ModalSlots>()
|
||||
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
|
||||
const contentProps = toRef(() => props.content)
|
||||
const contentEvents = computed(() => {
|
||||
if (props.preventClose) {
|
||||
return {
|
||||
'pointerDownOutside': (e: Event) => e.preventDefault(),
|
||||
'interactOutside': (e: Event) => e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const ui = computed(() => tv({ extend: modal, slots: props.ui })({
|
||||
transition: props.transition,
|
||||
fullscreen: props.fullscreen
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="rootProps">
|
||||
<DialogTrigger v-if="$slots.default" as-child>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogPortal :disabled="!portal">
|
||||
<DialogOverlay v-if="overlay" :class="ui.overlay()" />
|
||||
|
||||
<DialogContent :class="ui.content({ class: props.class })" v-bind="contentProps" v-on="contentEvents">
|
||||
<slot name="content">
|
||||
<div :class="ui.header()">
|
||||
<slot name="header">
|
||||
<DialogTitle v-if="title || $slots.title" :class="ui.title()">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription v-if="description || $slots.description" :class="ui.description()">
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</DialogDescription>
|
||||
|
||||
<DialogClose as-child>
|
||||
<slot name="close" :class="ui.close()">
|
||||
<UButton
|
||||
v-if="close !== null"
|
||||
:icon="appConfig.ui.icons.close"
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
aria-label="Close"
|
||||
v-bind="close"
|
||||
:class="ui.close()"
|
||||
/>
|
||||
</slot>
|
||||
</DialogClose>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.body" :class="ui.body()">
|
||||
<slot name="body" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" :class="ui.footer()">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</slot>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes modal-overlay-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes modal-overlay-closed {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes modal-content-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes modal-content-closed {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
92
src/runtime/components/NavigationMenu.vue
Normal file
92
src/runtime/components/NavigationMenu.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { NavigationMenuRootProps, NavigationMenuRootEmits } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/navigationMenu'
|
||||
import type { LinkProps } from '#ui/components/Link.vue'
|
||||
import type { AvatarProps } from '#ui/components/Avatar.vue'
|
||||
import type { BadgeProps } from '#ui/components/Badge.vue'
|
||||
import type { IconProps } from '#ui/components/Icon.vue'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { navigationMenu: Partial<typeof theme> } }
|
||||
|
||||
const navigationMenu = tv({ extend: tv(theme), ...(appConfig.ui?.navigationMenu || {}) })
|
||||
|
||||
export interface NavigationMenuLink extends LinkProps {
|
||||
label: string | number
|
||||
icon?: IconProps['name']
|
||||
avatar?: AvatarProps
|
||||
badge?: string | number | BadgeProps
|
||||
}
|
||||
|
||||
export interface NavigationMenuProps<T extends NavigationMenuLink> extends Omit<NavigationMenuRootProps, 'asChild' | 'dir'> {
|
||||
links: T[][] | T[]
|
||||
class?: any
|
||||
ui?: Partial<typeof navigationMenu.slots>
|
||||
}
|
||||
|
||||
export interface NavigationMenuEmits extends NavigationMenuRootEmits {}
|
||||
|
||||
type SlotProps<T> = (props: { link: T, active: boolean }) => any
|
||||
|
||||
export interface NavigationMenuSlots<T extends NavigationMenuLink> {
|
||||
leading: SlotProps<T>
|
||||
default: SlotProps<T>
|
||||
trailing: SlotProps<T>
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends NavigationMenuLink">
|
||||
import { computed } from 'vue'
|
||||
import { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuLink, useForwardPropsEmits } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import { UIcon, UAvatar, UBadge, ULink, ULinkBase } from '#components'
|
||||
import { omit } from '#ui/utils'
|
||||
|
||||
const props = withDefaults(defineProps<NavigationMenuProps<T>>(), { orientation: 'horizontal' })
|
||||
const emits = defineEmits<NavigationMenuEmits>()
|
||||
defineSlots<NavigationMenuSlots<T>>()
|
||||
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'delayDuration', 'skipDelayDuration', 'orientation'), emits)
|
||||
|
||||
const ui = computed(() => tv({ extend: navigationMenu, slots: props.ui })({ orientation: props.orientation }))
|
||||
|
||||
const lists = computed(() => props.links?.length ? (Array.isArray(props.links[0]) ? props.links : [props.links]) as T[][] : [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
|
||||
<NavigationMenuList v-for="(list, index) in lists" :key="`list-${index}`" :class="ui.list()">
|
||||
<NavigationMenuItem v-for="(link, subIndex) in list" :key="`list-${index}-${subIndex}`" :class="ui.item()">
|
||||
<ULink v-slot="{ active, ...slotProps }" v-bind="omit(link, ['label', 'icon', 'avatar', 'badge'])" custom>
|
||||
<NavigationMenuLink as-child :active="active">
|
||||
<ULinkBase v-bind="slotProps" :class="ui.base({ active })">
|
||||
<slot name="leading" :link="link" :active="active">
|
||||
<UAvatar v-if="link.avatar" size="2xs" v-bind="link.avatar" :class="ui.avatar({ active })" />
|
||||
<UIcon v-else-if="link.icon" :name="link.icon" :class="ui.icon({ active })" />
|
||||
</slot>
|
||||
|
||||
<span v-if="link.label || $slots.default" :class="ui.label()">
|
||||
<slot :link="link" :active="active">
|
||||
{{ link.label }}
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<slot name="trailing" :link="link" :active="active">
|
||||
<UBadge
|
||||
v-if="link.badge"
|
||||
color="gray"
|
||||
variant="solid"
|
||||
size="xs"
|
||||
v-bind="(typeof link.badge === 'string' || typeof link.badge === 'number') ? { label: link.badge } : link.badge"
|
||||
:class="ui.badge()"
|
||||
/>
|
||||
</slot>
|
||||
</ULinkBase>
|
||||
</NavigationMenuLink>
|
||||
</ULink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenuRoot>
|
||||
</template>
|
||||
155
src/runtime/components/Popover.vue
Normal file
155
src/runtime/components/Popover.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { PopoverRootProps, HoverCardRootProps, PopoverRootEmits, PopoverContentProps, PopoverArrowProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/popover'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { popover: Partial<typeof theme> } }
|
||||
|
||||
const popover = tv({ extend: tv(theme), ...(appConfig.ui?.popover || {}) })
|
||||
|
||||
export interface PopoverProps extends PopoverRootProps, Pick<HoverCardRootProps, 'openDelay' | 'closeDelay'>{
|
||||
/**
|
||||
* The mode of the popover.
|
||||
* @defaultValue "click"
|
||||
*/
|
||||
mode?: 'click' | 'hover'
|
||||
content?: Omit<PopoverContentProps, 'as' | 'asChild' | 'forceMount'>
|
||||
arrow?: boolean | Omit<PopoverArrowProps, 'as' | 'asChild'>
|
||||
portal?: boolean
|
||||
class?: any
|
||||
ui?: Partial<typeof popover.slots>
|
||||
}
|
||||
|
||||
export interface PopoverEmits extends PopoverRootEmits {}
|
||||
|
||||
export interface PopoverSlots {
|
||||
default(): any
|
||||
content(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { defu } from 'defu'
|
||||
import { useForwardPropsEmits } from 'radix-vue'
|
||||
import { Popover, HoverCard } from 'radix-vue/namespaced'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
|
||||
const props = withDefaults(defineProps<PopoverProps>(), {
|
||||
mode: 'click',
|
||||
openDelay: 0,
|
||||
closeDelay: 0
|
||||
})
|
||||
const emits = defineEmits<PopoverEmits>()
|
||||
defineSlots<PopoverSlots>()
|
||||
|
||||
const pick = props.mode === 'hover' ? reactivePick(props, 'defaultOpen', 'open', 'openDelay', 'closeDelay') : reactivePick(props, 'defaultOpen', 'open', 'modal')
|
||||
const rootProps = useForwardPropsEmits(pick, emits)
|
||||
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8 }) as PopoverContentProps)
|
||||
const arrowProps = toRef(() => props.arrow as PopoverArrowProps)
|
||||
|
||||
const ui = computed(() => tv({ extend: popover, slots: props.ui })())
|
||||
|
||||
const Component = computed(() => props.mode === 'hover' ? HoverCard : Popover)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component.Root v-bind="rootProps">
|
||||
<Component.Trigger v-if="$slots.default" as-child>
|
||||
<slot />
|
||||
</Component.Trigger>
|
||||
|
||||
<Component.Portal :disabled="!portal">
|
||||
<Component.Content v-bind="contentProps" :class="ui.content({ class: props.class })">
|
||||
<slot name="content" />
|
||||
|
||||
<Component.Arrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow()" />
|
||||
</Component.Content>
|
||||
</Component.Portal>
|
||||
</Component.Root>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes popover-down-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes popover-down-closed {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.25rem);
|
||||
}
|
||||
}
|
||||
@keyframes popover-right-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes popover-right-closed {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.25rem);
|
||||
}
|
||||
}
|
||||
@keyframes popover-up-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes popover-up-closed {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
}
|
||||
@keyframes popover-left-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes popover-left-closed {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(0.25rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
src/runtime/components/Provider.vue
Normal file
33
src/runtime/components/Provider.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { ConfigProviderProps, ToastProviderProps, TooltipProviderProps } from 'radix-vue'
|
||||
|
||||
export interface ProviderProps extends ConfigProviderProps {
|
||||
tooltip?: TooltipProviderProps
|
||||
toast?: ToastProviderProps
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue'
|
||||
import { ConfigProvider, ToastProvider, TooltipProvider, useForwardProps } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import { useId } from '#imports'
|
||||
|
||||
const props = withDefaults(defineProps<ProviderProps>(), {
|
||||
useId: () => useId()
|
||||
})
|
||||
|
||||
const configProps = useForwardProps(reactivePick(props, 'dir', 'scrollBody', 'useId'))
|
||||
const tooltipProps = toRef(() => props.tooltip as TooltipProviderProps)
|
||||
const toastProps = toRef(() => props.toast as ToastProviderProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider v-bind="configProps">
|
||||
<TooltipProvider v-bind="tooltipProps">
|
||||
<ToastProvider v-bind="toastProps">
|
||||
<slot />
|
||||
</ToastProvider>
|
||||
</TooltipProvider>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
27
src/runtime/components/Skeleton.vue
Normal file
27
src/runtime/components/Skeleton.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { PrimitiveProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/skeleton'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { skeleton: Partial<typeof theme> } }
|
||||
|
||||
const skeleton = tv({ extend: tv(theme), ...(appConfig.ui?.skeleton || {}) })
|
||||
|
||||
export interface SkeletonProps extends Omit<PrimitiveProps, 'asChild'> {
|
||||
class?: any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from 'radix-vue'
|
||||
|
||||
const props = withDefaults(defineProps<SkeletonProps>(), { as: 'div' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :class="skeleton({ class: props.class })">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
214
src/runtime/components/Slideover.vue
Normal file
214
src/runtime/components/Slideover.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { DialogRootProps, DialogRootEmits, DialogContentProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/slideover'
|
||||
import type { ButtonProps } from '#ui/components/Button.vue'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { slideover: Partial<typeof theme> } }
|
||||
|
||||
const slideover = tv({ extend: tv(theme), ...(appConfig.ui?.slideover || {}) })
|
||||
|
||||
export interface SlideoverProps extends DialogRootProps {
|
||||
title?: string
|
||||
description?: string
|
||||
content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'>
|
||||
overlay?: boolean
|
||||
transition?: boolean
|
||||
side?: 'left' | 'right' | 'top' | 'bottom'
|
||||
preventClose?: boolean
|
||||
portal?: boolean
|
||||
close?: ButtonProps | null
|
||||
class?: any
|
||||
ui?: Partial<typeof slideover.slots>
|
||||
}
|
||||
|
||||
export interface SlideoverEmits extends DialogRootEmits {}
|
||||
|
||||
export interface SlideoverSlots {
|
||||
default(): any
|
||||
content(): any
|
||||
header(): any
|
||||
title(): any
|
||||
description(): any
|
||||
close(): any
|
||||
body(): any
|
||||
footer(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose, useForwardPropsEmits } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import { useAppConfig } from '#app'
|
||||
import UButton from '#ui/components/Button.vue'
|
||||
|
||||
const props = withDefaults(defineProps<SlideoverProps>(), {
|
||||
portal: true,
|
||||
overlay: true,
|
||||
transition: true,
|
||||
side: 'right'
|
||||
})
|
||||
const emits = defineEmits<SlideoverEmits>()
|
||||
defineSlots<SlideoverSlots>()
|
||||
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
|
||||
const contentProps = toRef(() => props.content)
|
||||
const contentEvents = computed(() => {
|
||||
if (props.preventClose) {
|
||||
return {
|
||||
'pointerDownOutside': (e: Event) => e.preventDefault(),
|
||||
'interactOutside': (e: Event) => e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const ui = computed(() => tv({ extend: slideover, slots: props.ui })({
|
||||
transition: props.transition,
|
||||
side: props.side
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="rootProps">
|
||||
<DialogTrigger v-if="$slots.default" as-child>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogPortal :disabled="!portal">
|
||||
<DialogOverlay v-if="overlay" :class="ui.overlay()" />
|
||||
|
||||
<DialogContent :data-side="side" :class="ui.content({ class: props.class })" v-bind="contentProps" v-on="contentEvents">
|
||||
<slot name="content">
|
||||
<div :class="ui.header()">
|
||||
<slot name="header">
|
||||
<DialogTitle v-if="title || $slots.title" :class="ui.title()">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription v-if="description || $slots.description" :class="ui.description()">
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</DialogDescription>
|
||||
|
||||
<DialogClose as-child>
|
||||
<slot name="close" :class="ui.close()">
|
||||
<UButton
|
||||
v-if="close !== null"
|
||||
:icon="appConfig.ui.icons.close"
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
aria-label="Close"
|
||||
v-bind="close"
|
||||
:class="ui.close()"
|
||||
/>
|
||||
</slot>
|
||||
</DialogClose>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div :class="ui.body()">
|
||||
<slot name="body" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" :class="ui.footer()">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</slot>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes slideover-overlay-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes slideover-overlay-closed {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes slideover-content-right-open {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes slideover-content-right-closed {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
@keyframes slideover-content-left-open {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes slideover-content-left-closed {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
@keyframes slideover-content-top-open {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes slideover-content-top-closed {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
@keyframes slideover-content-bottom-open {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes slideover-content-bottom-closed {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
58
src/runtime/components/Switch.vue
Normal file
58
src/runtime/components/Switch.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { SwitchRootProps, SwitchRootEmits } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/switch'
|
||||
import type { IconProps } from '#ui/components/Icon.vue'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { switch: Partial<typeof theme> } }
|
||||
|
||||
const switchTv = tv({ extend: tv(theme), ...(appConfig.ui?.switch || {}) })
|
||||
|
||||
type SwitchVariants = VariantProps<typeof switchTv>
|
||||
|
||||
export interface SwitchProps extends Omit<SwitchRootProps, 'asChild'> {
|
||||
color?: SwitchVariants['color']
|
||||
size?: SwitchVariants['size']
|
||||
loading?: boolean
|
||||
loadingIcon?: IconProps['name']
|
||||
checkedIcon?: IconProps['name']
|
||||
uncheckedIcon?: IconProps['name']
|
||||
class?: any
|
||||
ui?: Partial<typeof switchTv.slots>
|
||||
}
|
||||
|
||||
export interface SwitchEmits extends SwitchRootEmits {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { SwitchRoot, SwitchThumb, useForwardPropsEmits } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import { useAppConfig } from '#app'
|
||||
|
||||
const props = defineProps<SwitchProps>()
|
||||
const emits = defineEmits<SwitchEmits>()
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultChecked', 'checked', 'required', 'name', 'id', 'value'), emits)
|
||||
|
||||
const ui = computed(() => tv({ extend: switchTv, slots: props.ui })({
|
||||
color: props.color,
|
||||
size: props.size,
|
||||
loading: props.loading
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot :disabled="disabled || loading" v-bind="rootProps" :class="ui.root({ class: props.class })">
|
||||
<SwitchThumb :class="ui.thumb()">
|
||||
<UIcon v-if="loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.icon({ checked: true, unchecked: true })" />
|
||||
<template v-else>
|
||||
<UIcon v-if="checkedIcon" :name="checkedIcon" :class="ui.icon({ checked: true })" />
|
||||
<UIcon v-if="uncheckedIcon" :name="uncheckedIcon" :class="ui.icon({ unchecked: true })" />
|
||||
</template>
|
||||
</SwitchThumb>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
70
src/runtime/components/Tabs.vue
Normal file
70
src/runtime/components/Tabs.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { TabsRootProps, TabsRootEmits } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/tabs'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { tabs: Partial<typeof theme> } }
|
||||
|
||||
const tabs = tv({ extend: tv(theme), ...(appConfig.ui?.tabs || {}) })
|
||||
|
||||
export interface TabsItem {
|
||||
label?: string
|
||||
value?: string
|
||||
slot?: string
|
||||
disabled?: boolean
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface TabsProps<T extends TabsItem> extends Omit<TabsRootProps, 'asChild'> {
|
||||
items: T[]
|
||||
class?: any
|
||||
ui?: Partial<typeof tabs.slots>
|
||||
}
|
||||
|
||||
export interface TabsEmits extends TabsRootEmits {}
|
||||
|
||||
type SlotProps<T> = (props: { item: T, index: number }) => any
|
||||
|
||||
export type TabsSlots<T extends TabsItem> = {
|
||||
default: SlotProps<T>
|
||||
content: SlotProps<T>
|
||||
} & {
|
||||
[key in T['slot'] as string]?: SlotProps<T>
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends TabsItem">
|
||||
import { computed } from 'vue'
|
||||
import { TabsRoot, TabsList, TabsIndicator, TabsTrigger, TabsContent, useForwardPropsEmits } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
|
||||
const props = withDefaults(defineProps<TabsProps<T>>(), { defaultValue: '0' })
|
||||
const emits = defineEmits<TabsEmits>()
|
||||
defineSlots<TabsSlots<T>>()
|
||||
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultValue', 'orientation', 'activationMode', 'modelValue'), emits)
|
||||
|
||||
const ui = computed(() => tv({ extend: tabs, slots: props.ui })())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
|
||||
<TabsList :class="ui.list()">
|
||||
<TabsIndicator :class="ui.indicator()" />
|
||||
|
||||
<TabsTrigger v-for="(item, index) of items" :key="index" :value="item.value || String(index)" :disabled="item.disabled" :class="ui.trigger()">
|
||||
<span v-if="item.label || $slots.default" :class="ui.label()">
|
||||
<slot :item="item" :index="index">{{ item.label }}</slot>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent v-for="(item, index) of items" :key="index" force-mount :value="item.value || String(index)" :class="ui.content()">
|
||||
<slot :name="item.slot || 'content'" :item="item" :index="index">
|
||||
{{ item.content }}
|
||||
</slot>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</template>
|
||||
174
src/runtime/components/Textarea.vue
Normal file
174
src/runtime/components/Textarea.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/textarea'
|
||||
import { looseToNumber } from '#ui/utils'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { textarea: Partial<typeof theme> } }
|
||||
|
||||
const textarea = tv({ extend: tv(theme), ...(appConfig.ui?.textarea || {}) })
|
||||
|
||||
type TextareaVariants = VariantProps<typeof textarea>
|
||||
|
||||
export interface TextareaProps {
|
||||
id?: string
|
||||
name?: string
|
||||
placeholder?: string
|
||||
color?: TextareaVariants['color']
|
||||
variant?: TextareaVariants['variant']
|
||||
size?: TextareaVariants['size']
|
||||
required?: boolean
|
||||
autofocus?: boolean
|
||||
autofocusDelay?: number
|
||||
disabled?: boolean
|
||||
class?: any
|
||||
rows?: number
|
||||
maxrows?: number
|
||||
autoresize?: boolean
|
||||
ui?: Partial<typeof textarea.slots>
|
||||
}
|
||||
|
||||
export interface TextareaEmits {
|
||||
(e: 'blur', event: FocusEvent): void
|
||||
}
|
||||
|
||||
export interface TextareaSlots {
|
||||
default(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useFormField } from '#ui/composables/useFormField'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<TextareaProps>(), {
|
||||
rows: 3,
|
||||
maxrows: 0,
|
||||
autofocusDelay: 100
|
||||
})
|
||||
|
||||
const emit = defineEmits<TextareaEmits>()
|
||||
defineSlots<TextareaSlots>()
|
||||
|
||||
const [modelValue, modelModifiers] = defineModel<string | number>()
|
||||
|
||||
const { emitFormBlur, emitFormInput, size, color, inputId, name, disabled } = useFormField<TextareaProps>(props)
|
||||
|
||||
const ui = computed(() => tv({ extend: textarea, slots: props.ui })({
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: size?.value
|
||||
}))
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function autoFocus () {
|
||||
if (props.autofocus) {
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Custom function to handle the v-model properties
|
||||
function updateInput (value: string) {
|
||||
if (modelModifiers.trim) {
|
||||
value = value.trim()
|
||||
}
|
||||
|
||||
if (modelModifiers.number) {
|
||||
value = looseToNumber(value)
|
||||
}
|
||||
|
||||
modelValue.value = value
|
||||
emitFormInput()
|
||||
}
|
||||
|
||||
function onInput (event: Event) {
|
||||
autoResize()
|
||||
|
||||
if (!modelModifiers.lazy) {
|
||||
updateInput((event.target as HTMLInputElement).value)
|
||||
}
|
||||
}
|
||||
|
||||
function onChange (event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
|
||||
if (modelModifiers.lazy) {
|
||||
updateInput(value)
|
||||
}
|
||||
|
||||
// Update trimmed textarea so that it has same behavior as native textarea https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
|
||||
if (modelModifiers.trim) {
|
||||
(event.target as HTMLInputElement).value = value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur (event: FocusEvent) {
|
||||
emitFormBlur()
|
||||
emit('blur', event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
function autoResize () {
|
||||
if (props.autoresize) {
|
||||
|
||||
if (!textareaRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
textareaRef.value.rows = props.rows
|
||||
|
||||
const styles = window.getComputedStyle(textareaRef.value)
|
||||
const paddingTop = parseInt(styles.paddingTop)
|
||||
const paddingBottom = parseInt(styles.paddingBottom)
|
||||
const padding = paddingTop + paddingBottom
|
||||
const lineHeight = parseInt(styles.lineHeight)
|
||||
const { scrollHeight } = textareaRef.value
|
||||
const newRows = (scrollHeight - padding) / lineHeight
|
||||
|
||||
if (newRows > props.rows) {
|
||||
textareaRef.value.rows = props.maxrows ? Math.min(newRows, props.maxrows) : newRows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => modelValue, () => {
|
||||
nextTick(autoResize)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autoResize()
|
||||
}, 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="ui.root({ class: props.class })">
|
||||
<textarea
|
||||
:id="inputId"
|
||||
ref="textareaRef"
|
||||
:value="modelValue"
|
||||
:name="name"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
:class="ui.base()"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
v-bind="$attrs"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@change="onChange"
|
||||
/>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
118
src/runtime/components/Tooltip.vue
Normal file
118
src/runtime/components/Tooltip.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { TooltipRootProps, TooltipRootEmits, TooltipContentProps, TooltipArrowProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/tooltip'
|
||||
import type { KbdProps } from '#ui/components/Kbd.vue'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { tooltip: Partial<typeof theme> } }
|
||||
|
||||
const tooltip = tv({ extend: tv(theme), ...(appConfig.ui?.tooltip || {}) })
|
||||
|
||||
export interface TooltipProps extends TooltipRootProps {
|
||||
text?: string
|
||||
shortcuts?: string[] | KbdProps[]
|
||||
content?: Omit<TooltipContentProps, 'as' | 'asChild'>
|
||||
arrow?: boolean | Omit<TooltipArrowProps, 'as' | 'asChild'>
|
||||
portal?: boolean
|
||||
class?: any
|
||||
ui?: Partial<typeof tooltip.slots>
|
||||
}
|
||||
|
||||
export interface TooltipEmits extends TooltipRootEmits {}
|
||||
|
||||
export interface TooltipSlots {
|
||||
default(): any
|
||||
content(): any
|
||||
text(): any
|
||||
shortcuts(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { defu } from 'defu'
|
||||
import { TooltipRoot, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArrow, useForwardPropsEmits } from 'radix-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import UKbd from '#ui/components/Kbd.vue'
|
||||
|
||||
const props = defineProps<TooltipProps>()
|
||||
const emits = defineEmits<TooltipEmits>()
|
||||
defineSlots<TooltipSlots>()
|
||||
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultOpen', 'open', 'delayDuration'), emits)
|
||||
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8 }) as TooltipContentProps)
|
||||
const arrowProps = toRef(() => props.arrow as TooltipArrowProps)
|
||||
|
||||
const ui = computed(() => tv({ extend: tooltip, slots: props.ui })())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipRoot v-bind="rootProps">
|
||||
<TooltipTrigger v-if="$slots.default" as-child>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipPortal :disabled="!portal">
|
||||
<TooltipContent v-bind="contentProps" :class="ui.content({ class: props.class })">
|
||||
<slot name="content">
|
||||
<span v-if="text" :class="ui.text()">
|
||||
<slot name="text">{{ text }}</slot>
|
||||
</span>
|
||||
|
||||
<span v-if="shortcuts?.length" :class="ui.shortcuts()">
|
||||
<slot name="shortcuts">
|
||||
<UKbd v-for="(shortcut, index) in shortcuts" :key="index" size="xs" v-bind="typeof shortcut === 'string' ? { value: shortcut } : shortcut" />
|
||||
</slot>
|
||||
</span>
|
||||
</slot>
|
||||
|
||||
<TooltipArrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow()" />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes tooltip-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes tooltip-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes tooltip-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes tooltip-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user