This commit is contained in:
Benjamin Canac
2024-03-27 12:34:25 +01:00
155 changed files with 11236 additions and 3062 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View 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
}
}

View 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
View File

@@ -0,0 +1,5 @@
declare module '#build/app.config' {
import type { AppConfig } from '@nuxt/schema'
const _default: AppConfig
export default _default
}

View File

@@ -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>
}

View File

@@ -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
View 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 []
}

View File

@@ -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'