mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-21 23:40:39 +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>
|
||||
42
src/runtime/composables/useComponentIcons.ts
Normal file
42
src/runtime/composables/useComponentIcons.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAppConfig } from '#app'
|
||||
import type { IconProps } from '#ui/components/Icon.vue'
|
||||
|
||||
export interface UseComponentIconsProps {
|
||||
icon?: IconProps['name']
|
||||
leading?: boolean
|
||||
leadingIcon?: IconProps['name']
|
||||
trailing?: boolean
|
||||
trailingIcon?: IconProps['name']
|
||||
loading?: boolean
|
||||
loadingIcon?: IconProps['name']
|
||||
}
|
||||
|
||||
export function useComponentIcons (props: UseComponentIconsProps) {
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const isLeading = computed(() => (props.icon && props.leading) || (props.icon && !props.trailing) || (props.loading && !props.trailing && !props.trailingIcon) || !!props.leadingIcon)
|
||||
const isTrailing = computed(() => (props.icon && props.trailing) || (props.loading && props.trailing) || !!props.trailingIcon)
|
||||
|
||||
const leadingIconName = computed(() => {
|
||||
if (props.loading) {
|
||||
return props.loadingIcon || appConfig.ui.icons.loading
|
||||
}
|
||||
|
||||
return props.leadingIcon || props.icon
|
||||
})
|
||||
const trailingIconName = computed(() => {
|
||||
if (props.loading && !isLeading.value) {
|
||||
return props.loadingIcon || appConfig.ui.icons.loading
|
||||
}
|
||||
|
||||
return props.trailingIcon || props.icon
|
||||
})
|
||||
|
||||
return {
|
||||
isLeading,
|
||||
isTrailing,
|
||||
leadingIconName,
|
||||
trailingIconName
|
||||
}
|
||||
}
|
||||
70
src/runtime/composables/useFormField.ts
Normal file
70
src/runtime/composables/useFormField.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { inject, ref, computed } from 'vue'
|
||||
import { type UseEventBusReturn, useDebounceFn } from '@vueuse/core'
|
||||
import type { FormEvent, FormInputEvents, FormFieldInjectedOptions, FormInjectedOptions } from '#ui/types/form'
|
||||
|
||||
type Props<T> = {
|
||||
id?: string
|
||||
name?: string
|
||||
// @ts-ignore FIXME: TS doesn't like this
|
||||
size?: T['size']
|
||||
// @ts-ignore FIXME: TS doesn't like this
|
||||
color?: T['color']
|
||||
eagerValidation?: boolean
|
||||
legend?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function useFormField <T> (inputProps?: Props<T>) {
|
||||
const formOptions = inject<FormInjectedOptions | undefined>('form-options', undefined)
|
||||
const formBus = inject<UseEventBusReturn<FormEvent, string> | undefined>('form-events', undefined)
|
||||
const formField = inject<FormFieldInjectedOptions<T> | undefined>('form-field', undefined)
|
||||
const formInputs = inject<any>('form-inputs', undefined)
|
||||
|
||||
if (formField) {
|
||||
if (inputProps?.id) {
|
||||
// Updates for="..." attribute on label if inputProps.id is provided
|
||||
formField.inputId.value = inputProps?.id
|
||||
}
|
||||
|
||||
if (formInputs && formField.name.value) {
|
||||
formInputs.value[formField.name.value] = formField.inputId.value
|
||||
}
|
||||
}
|
||||
|
||||
const blurred = ref(false)
|
||||
|
||||
function emitFormEvent (type: FormInputEvents, name: string) {
|
||||
if (formBus && formField) {
|
||||
formBus.emit({ type, name })
|
||||
}
|
||||
}
|
||||
|
||||
function emitFormBlur () {
|
||||
emitFormEvent('blur', formField?.name.value as string)
|
||||
blurred.value = true
|
||||
}
|
||||
|
||||
function emitFormChange () {
|
||||
emitFormEvent('change', formField?.name.value as string)
|
||||
}
|
||||
|
||||
const emitFormInput = useDebounceFn(
|
||||
() => {
|
||||
if (blurred.value || formField?.eagerValidation.value) {
|
||||
emitFormEvent('input', formField?.name.value as string)
|
||||
}
|
||||
},
|
||||
formField?.validateOnInputDelay.value ?? formOptions?.validateOnInputDelay?.value ?? 0
|
||||
)
|
||||
|
||||
return {
|
||||
inputId: computed(() => inputProps?.id ?? formField?.inputId.value),
|
||||
name: computed(() => inputProps?.name ?? formField?.name.value),
|
||||
size: computed(() => inputProps?.size ?? formField?.size?.value),
|
||||
color: computed(() => formField?.error?.value ? 'red' : inputProps?.color),
|
||||
disabled: computed(() => formOptions?.disabled?.value || inputProps?.disabled),
|
||||
emitFormBlur,
|
||||
emitFormInput,
|
||||
emitFormChange
|
||||
}
|
||||
}
|
||||
46
src/runtime/plugins/index.ts
Normal file
46
src/runtime/plugins/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { computed } from 'vue'
|
||||
import { defineNuxtPlugin, useAppConfig, useNuxtApp, useHead } from '#imports'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const appConfig = useAppConfig()
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
|
||||
|
||||
const root = computed(() => {
|
||||
return `:root {
|
||||
${shades.map(shade => `--color-primary-${shade}: var(--color-${appConfig.ui.primary}-${shade});`).join('\n')}
|
||||
--color-primary-DEFAULT: var(--color-primary-500);
|
||||
${shades.map(shade => `--color-gray-${shade}: var(--color-${appConfig.ui.gray}-${shade});`).join('\n')}
|
||||
}
|
||||
.dark {
|
||||
--color-primary-DEFAULT: var(--color-primary-400);
|
||||
}
|
||||
`
|
||||
})
|
||||
|
||||
// Head
|
||||
const headData: any = {
|
||||
style: [{
|
||||
innerHTML: () => root.value,
|
||||
tagPriority: -2,
|
||||
id: 'nuxt-ui-colors',
|
||||
type: 'text/css'
|
||||
}]
|
||||
}
|
||||
|
||||
// SPA mode
|
||||
if (import.meta.client && nuxtApp.isHydrating && !nuxtApp.payload.serverRendered) {
|
||||
const style = document.createElement('style')
|
||||
|
||||
style.innerHTML = root.value
|
||||
style.setAttribute('data-nuxt-ui-colors', '')
|
||||
document.head.appendChild(style)
|
||||
|
||||
headData.script = [{
|
||||
innerHTML: 'document.head.removeChild(document.querySelector(\'[data-nuxt-ui-colors]\'))'
|
||||
}]
|
||||
}
|
||||
|
||||
useHead(headData)
|
||||
})
|
||||
5
src/runtime/types/app.config.d.ts
vendored
Normal file
5
src/runtime/types/app.config.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module '#build/app.config' {
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
const _default: AppConfig
|
||||
export default _default
|
||||
}
|
||||
82
src/runtime/types/form.d.ts
vendored
82
src/runtime/types/form.d.ts
vendored
@@ -1,7 +1,25 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
export interface FormError<T extends string = string> {
|
||||
path: T
|
||||
export interface Form<T> {
|
||||
validate (opts?: { name: string | string[], silent?: false, nested?: boolean }): Promise<T | false>
|
||||
clear (path?: string): void
|
||||
errors: Ref<FormError[]>
|
||||
setErrors (errs: FormError[], path?: string): void
|
||||
getErrors (path?: string): FormError[]
|
||||
submit (): Promise<void>
|
||||
disabled: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export type FormSchema<T extends object> =
|
||||
| ZodSchema
|
||||
| YupObjectSchema<T>
|
||||
| ValibotObjectSchema<T>
|
||||
| JoiSchema<T>
|
||||
|
||||
export type FormInputEvents = 'input' | 'blur' | 'change'
|
||||
|
||||
export interface FormError<P extends string = string> {
|
||||
name: P
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -9,30 +27,48 @@ export interface FormErrorWithId extends FormError {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface Form<T> {
|
||||
validate(path?: string | string[], opts?: { silent?: true }): Promise<T | false>;
|
||||
validate(path?: string | string[], opts?: { silent?: false }): Promise<T>;
|
||||
clear(path?: string): void
|
||||
errors: Ref<FormError[]>
|
||||
setErrors(errs: FormError[], path?: string): void
|
||||
getErrors(path?: string): FormError[]
|
||||
submit(): Promise<void>
|
||||
}
|
||||
|
||||
export type FormSubmitEvent<T> = SubmitEvent & { data: T }
|
||||
export type FormErrorEvent = SubmitEvent & { errors: FormErrorWithId[] }
|
||||
|
||||
export type FormEventType = 'blur' | 'input' | 'change' | 'submit'
|
||||
export type FormValidationError = {
|
||||
errors: FormErrorWithId[]
|
||||
childrens: FormValidationError[]
|
||||
}
|
||||
|
||||
export interface FormEvent {
|
||||
export type FormErrorEvent = SubmitEvent & FormValidationError
|
||||
|
||||
export type FormEventType = FormInputEvents
|
||||
|
||||
export type FormChildAttachEvent = {
|
||||
type: 'attach'
|
||||
formId: string | number
|
||||
validate: Form<any>['validate']
|
||||
}
|
||||
|
||||
export type FormChildDetachEvent = {
|
||||
type: 'detach'
|
||||
formId: string | number
|
||||
}
|
||||
|
||||
export type FormInputEvent = {
|
||||
type: FormEventType
|
||||
path?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface InjectedFormGroupValue {
|
||||
inputId: Ref<string | undefined>
|
||||
name: Ref<string>
|
||||
size: Ref<string | number | symbol>
|
||||
error: Ref<string | boolean | undefined>
|
||||
eagerValidation: Ref<boolean>
|
||||
export type FormEvent =
|
||||
| FormInputEvent
|
||||
| FormChildAttachEvent
|
||||
| FormChildDetachEvent
|
||||
|
||||
export interface FormInjectedOptions {
|
||||
disabled?: ComputedRef<boolean>
|
||||
validateOnInputDelay?: ComputedRef<number>
|
||||
}
|
||||
|
||||
export interface FormFieldInjectedOptions<T> {
|
||||
inputId: Ref<string | undefined>
|
||||
name: ComputedRef<string | undefined>
|
||||
size: ComputedRef<T['size']>
|
||||
error: ComputedRef<string | boolean | undefined>
|
||||
eagerValidation: ComputedRef<boolean | undefined>
|
||||
validateOnInputDelay: ComputedRef<number | undefined>
|
||||
}
|
||||
|
||||
31
src/runtime/types/index.d.ts
vendored
31
src/runtime/types/index.d.ts
vendored
@@ -1,31 +0,0 @@
|
||||
export * from './accordion'
|
||||
export * from './alert'
|
||||
export * from './avatar'
|
||||
export * from './badge'
|
||||
export * from './breadcrumb'
|
||||
export * from './button'
|
||||
export * from './chip'
|
||||
export * from './clipboard'
|
||||
export * from './command-palette'
|
||||
export * from './divider'
|
||||
export * from './dropdown'
|
||||
export * from './form-group'
|
||||
export * from './form'
|
||||
export * from './horizontal-navigation'
|
||||
export * from './input'
|
||||
export * from './kbd'
|
||||
export * from './link'
|
||||
export * from './meter'
|
||||
export * from './modal'
|
||||
export * from './slideover'
|
||||
export * from './notification'
|
||||
export * from './popper'
|
||||
export * from './progress'
|
||||
export * from './range'
|
||||
export * from './select'
|
||||
export * from './tabs'
|
||||
export * from './textarea'
|
||||
export * from './toggle'
|
||||
export * from './tooltip'
|
||||
export * from './vertical-navigation'
|
||||
export * from './utils'
|
||||
|
||||
83
src/runtime/utils/form.ts
Normal file
83
src/runtime/utils/form.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ZodSchema } from 'zod'
|
||||
import type { ValidationError as JoiError, Schema as JoiSchema } from 'joi'
|
||||
import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup'
|
||||
import type { ObjectSchemaAsync as ValibotObjectSchema } from 'valibot'
|
||||
import type { FormError } from '#ui/types/form'
|
||||
|
||||
export function isYupSchema (schema: any): schema is YupObjectSchema<any> {
|
||||
return schema.validate && schema.__isYupSchema__
|
||||
}
|
||||
|
||||
export function isYupError (error: any): error is YupError {
|
||||
return error.inner !== undefined
|
||||
}
|
||||
|
||||
export async function getYupErrors (state: any, schema: YupObjectSchema<any>): Promise<FormError[]> {
|
||||
try {
|
||||
await schema.validate(state, { abortEarly: false })
|
||||
return []
|
||||
} catch (error) {
|
||||
if (isYupError(error)) {
|
||||
return error.inner.map((issue) => ({
|
||||
name: issue.path ?? '',
|
||||
message: issue.message
|
||||
}))
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isZodSchema (schema: any): schema is ZodSchema {
|
||||
return schema.parse !== undefined
|
||||
}
|
||||
|
||||
export async function getZodErrors (state: any, schema: ZodSchema): Promise<FormError[]> {
|
||||
const result = await schema.safeParseAsync(state)
|
||||
if (result.success === false) {
|
||||
return result.error.issues.map((issue) => ({
|
||||
name: issue.path.join('.'),
|
||||
message: issue.message
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function isJoiSchema (schema: any): schema is JoiSchema {
|
||||
return schema.validateAsync !== undefined && schema.id !== undefined
|
||||
}
|
||||
|
||||
export function isJoiError (error: any): error is JoiError {
|
||||
return error.isJoi === true
|
||||
}
|
||||
|
||||
export async function getJoiErrors (state: any, schema: JoiSchema): Promise<FormError[]> {
|
||||
try {
|
||||
await schema.validateAsync(state, { abortEarly: false })
|
||||
return []
|
||||
} catch (error) {
|
||||
if (isJoiError(error)) {
|
||||
return error.details.map((detail) => ({
|
||||
name: detail.path.join('.'),
|
||||
message: detail.message
|
||||
}))
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isValibotSchema (schema: any): schema is ValibotObjectSchema<any> {
|
||||
return schema._parse !== undefined
|
||||
}
|
||||
|
||||
export async function getValibotError (state: any, schema: ValibotObjectSchema<any>): Promise<FormError[]> {
|
||||
const result = await schema._parse(state)
|
||||
if (result.issues) {
|
||||
return result.issues.map((issue) => ({
|
||||
name: issue.path?.map((p) => p.key).join('.') || '',
|
||||
message: issue.message
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -1,86 +1,24 @@
|
||||
import { defu, createDefu } from 'defu'
|
||||
import { extendTailwindMerge } from 'tailwind-merge'
|
||||
import type { Strategy } from '../types'
|
||||
export function pick<Data extends object, Keys extends keyof Data> (data: Data, keys: Keys[]): Pick<Data, Keys> {
|
||||
const result = {} as Pick<Data, Keys>
|
||||
|
||||
const customTwMerge = extendTailwindMerge<string, string>({
|
||||
extend: {
|
||||
classGroups: {
|
||||
icons: [(classPart: string) => /^i-/.test(classPart)]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const defuTwMerge = createDefu((obj, key, value, namespace) => {
|
||||
if (namespace === 'default' || namespace.startsWith('default.')) {
|
||||
return false
|
||||
}
|
||||
if (namespace === 'popper' || namespace.startsWith('popper.')) {
|
||||
return false
|
||||
}
|
||||
if (namespace.endsWith('avatar') && key === 'size') {
|
||||
return false
|
||||
}
|
||||
if (namespace.endsWith('chip') && key === 'size') {
|
||||
return false
|
||||
}
|
||||
if (namespace.endsWith('badge') && key === 'size' || key === 'color' || key === 'variant') {
|
||||
return false
|
||||
}
|
||||
if (typeof obj[key] === 'string' && typeof value === 'string' && obj[key] && value) {
|
||||
// @ts-ignore
|
||||
obj[key] = customTwMerge(obj[key], value)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
export function mergeConfig<T> (strategy: Strategy, ...configs): T {
|
||||
if (strategy === 'override') {
|
||||
return defu({}, ...configs) as T
|
||||
for (const key of keys) {
|
||||
result[key] = data[key]
|
||||
}
|
||||
|
||||
return defuTwMerge({}, ...configs) as T
|
||||
}
|
||||
|
||||
export function hexToRgb (hex: string) {
|
||||
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
|
||||
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
|
||||
hex = hex.replace(shorthandRegex, function (_, r, g, b) {
|
||||
return r + r + g + g + b + b
|
||||
})
|
||||
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result
|
||||
? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}`
|
||||
: null
|
||||
}
|
||||
|
||||
export function getSlotsChildren (slots: any) {
|
||||
let children = slots.default?.()
|
||||
if (children?.length) {
|
||||
children = children.flatMap(c => {
|
||||
if (typeof c.type === 'symbol') {
|
||||
if (typeof c.children === 'string') {
|
||||
// `v-if="false"` or commented node
|
||||
return
|
||||
}
|
||||
return c.children
|
||||
} else if (c.type.name === 'ContentSlot') {
|
||||
return c.ctx.slots.default?.()
|
||||
}
|
||||
return c
|
||||
}).filter(Boolean)
|
||||
export function omit<Data extends object, Keys extends keyof Data> (data: Data, keys: Keys[]): Omit<Data, Keys> {
|
||||
const result = { ...data }
|
||||
|
||||
for (const key of keys) {
|
||||
delete result[key]
|
||||
}
|
||||
return children || []
|
||||
|
||||
return result as Omit<Data, Keys>
|
||||
}
|
||||
|
||||
/**
|
||||
* "123-foo" will be parsed to 123
|
||||
* This is used for the .number modifier in v-model
|
||||
*/
|
||||
export function looseToNumber (val: any): any {
|
||||
const n = parseFloat(val)
|
||||
return isNaN(n) ? val : n
|
||||
}
|
||||
|
||||
export * from './lodash'
|
||||
export * from './link'
|
||||
|
||||
Reference in New Issue
Block a user