+
+export type FormInputEvents = 'input' | 'blur' | 'change'
+
+export interface FormError {
+ name: P
+ message: string
+}
+
+export interface FormErrorWithId extends FormError {
+ id: string
+}
+
+export type FormSubmitEvent = SubmitEvent & { data: T }
+export type FormErrorEvent = SubmitEvent & { errors: FormErrorWithId[] }
+
+export type FormEventType = FormInputEvents | 'submit'
+
+export interface FormEvent {
+ type: FormEventType
+ name?: string
+}
+
+export interface InjectedFormFieldOptions {
+ inputId: Ref
+ name: Computed
+ size: Computed
+ error: Computed
+ eagerValidation: Computed
+ validateOnInputDelay: Computed
+}
+
+export interface InjectedFormOptions {
+ disabled?: Computed
+ validateOnInputDelay?: Computed
+}
diff --git a/src/runtime/types/index.d.ts b/src/runtime/types/index.d.ts
index e69de29b..8b137891 100644
--- a/src/runtime/types/index.d.ts
+++ b/src/runtime/types/index.d.ts
@@ -0,0 +1 @@
+
diff --git a/src/runtime/utils/form.ts b/src/runtime/utils/form.ts
new file mode 100644
index 00000000..c7d6ae51
--- /dev/null
+++ b/src/runtime/utils/form.ts
@@ -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 '../types/form'
+
+export function isYupSchema (schema: any): schema is YupObjectSchema {
+ 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): Promise {
+ 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 {
+ 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 {
+ 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 {
+ return schema._parse !== undefined
+}
+
+export async function getValibotError (state: any, schema: ValibotObjectSchema): Promise {
+ 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 []
+}
diff --git a/src/runtime/utils/index.ts b/src/runtime/utils/index.ts
new file mode 100644
index 00000000..9ca3c401
--- /dev/null
+++ b/src/runtime/utils/index.ts
@@ -0,0 +1,4 @@
+export function looseToNumber (val: any): any {
+ const n = parseFloat(val)
+ return isNaN(n) ? val : n
+}
diff --git a/src/theme/form.ts b/src/theme/form.ts
new file mode 100644
index 00000000..1e31c0cb
--- /dev/null
+++ b/src/theme/form.ts
@@ -0,0 +1,3 @@
+export default {
+ base: ''
+}
diff --git a/src/theme/formField.ts b/src/theme/formField.ts
new file mode 100644
index 00000000..ba50fb7c
--- /dev/null
+++ b/src/theme/formField.ts
@@ -0,0 +1,32 @@
+export default {
+ slots: {
+ root: '',
+ wrapper: '',
+ labelWrapper: 'flex content-center items-center justify-between',
+ label: 'block font-medium text-gray-700 dark:text-gray-200',
+ container: 'mt-1 relative',
+ description: 'text-gray-500 dark:text-gray-400',
+ error: 'mt-2 text-red-500 dark:text-red-400',
+ hint: 'text-gray-500 dark:text-gray-400',
+ help: 'mt-2 text-gray-500 dark:text-gray-400'
+ },
+ variants: {
+ size: {
+ '2xs': { root: 'text-xs' },
+ xs: { root: 'text-xs' },
+ sm: { root: 'text-sm' },
+ md: { root: 'text-sm' },
+ lg: { root: 'text-sm' },
+ xl: { root: 'text-base' }
+ },
+ required: {
+ true: {
+ // eslint-disable-next-line quotes
+ label: `after:content-['*'] after:ms-0.5 after:text-red-500 dark:after:text-red-400`
+ }
+ }
+ },
+ defaultVariants: {
+ size: 'sm'
+ }
+}
diff --git a/src/theme/index.ts b/src/theme/index.ts
index faaa1420..c879f078 100644
--- a/src/theme/index.ts
+++ b/src/theme/index.ts
@@ -5,7 +5,10 @@ export { default as card } from './card'
export { default as chip } from './chip'
export { default as collapsible } from './collapsible'
export { default as container } from './container'
+export { default as form } from './form'
+export { default as formField } from './formField'
export { default as icons } from './icons'
+export { default as input } from './input'
export { default as kbd } from './kbd'
export { default as modal } from './modal'
export { default as popover } from './popover'
diff --git a/src/theme/input.ts b/src/theme/input.ts
new file mode 100644
index 00000000..6af9931e
--- /dev/null
+++ b/src/theme/input.ts
@@ -0,0 +1,66 @@
+export default (config: { colors: string[] }) => {
+ return {
+ slots: {
+ root: 'relative',
+ base: 'relative block w-full disabled:cursor-not-allowed disabled:opacity-75 focus:outline-none border-0 rounded-md placeholder-gray-400 dark:placeholder-gray-500',
+ icon: '',
+ leading: '',
+ trailing: ''
+ },
+ variants: {
+ size: {
+ '2xs': {
+ base: 'text-xs gap-x-1 px-2 py-1',
+ icon: 'h-4 w-4'
+ },
+ xs: {
+ base: 'text-sm gap-x-1.5 px-2.5 py-1.5',
+ icon: 'size-4'
+ },
+ sm: {
+ base: 'text-sm gap-x-1.5 px-2.5 py-1.5',
+ icon: 'size-5'
+ },
+ md: {
+ base: 'text-sm gap-x-1.5 px-3 py-2',
+ icon: 'size-5'
+ },
+ lg: {
+ base: 'text-sm gap-x-2.5 px-3.5 py-2.5',
+ icon: 'size-5'
+ },
+ xl: {
+ base: 'text-base gap-x-2.5 px-3.5 py-2.5',
+ icon: 'size-6'
+ }
+ },
+ variant: {
+ outline: '',
+ none: 'bg-transparent focus:ring-0 focus:shadow-none'
+ },
+ color: {
+ ...Object.fromEntries(config.colors.map((color: string) => [color, ''])),
+ white: '',
+ gray: ''
+ }
+ },
+ compoundVariants: [...config.colors.map((color: string) => ({
+ color,
+ variant: 'outline',
+ class: `shadow-sm bg-transparent text-gray-900 dark:text-white ring-1 ring-inset ring-${color}-500 dark:ring-${color}-400 focus:ring-2 focus:ring-${color}-500 dark:focus:ring-${color}-400`
+ })), {
+ color: 'white',
+ variant: 'outline',
+ class: 'shadow-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400'
+ }, {
+ color: 'gray',
+ variant: 'outline',
+ class: 'shadow-sm bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400'
+ }],
+ defaultVariants: {
+ size: 'sm',
+ color: 'white',
+ variant: 'outline'
+ }
+ }
+}
diff --git a/test/component-render.ts b/test/component-render.ts
index f0b48df7..ab1ec2be 100644
--- a/test/component-render.ts
+++ b/test/component-render.ts
@@ -3,7 +3,7 @@ import path from 'path'
export default async function (nameOrHtml: string, options: any, component: any) {
let html: string
- const name = path.parse(component.__file).name
+ const name = component.__file ? path.parse(component.__file).name : undefined
if (options === undefined) {
const app = {
template: nameOrHtml,
diff --git a/test/components/Form.spec.ts b/test/components/Form.spec.ts
new file mode 100644
index 00000000..d72f7b19
--- /dev/null
+++ b/test/components/Form.spec.ts
@@ -0,0 +1,450 @@
+import { reactive } from 'vue'
+import { describe, it, expect } from 'vitest'
+import type { FormProps } from '../../src/runtime/components/Form.vue'
+import {
+ UForm,
+ UInput,
+ UFormField
+ // URadioGroup,
+ // UTextarea,
+ // UCheckbox,
+ // USelect,
+ // URadio,
+ // USelectMenu,
+ // UInputMenu,
+ // UToggle,
+ // URange
+} from '#components'
+import { DOMWrapper, flushPromises, VueWrapper } from '@vue/test-utils'
+import ComponentRender from '../component-render'
+
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { z } from 'zod'
+import * as yup from 'yup'
+import Joi from 'joi'
+import * as valibot from 'valibot'
+
+async function triggerEvent (
+ el: DOMWrapper | VueWrapper,
+ event: string
+) {
+ el.trigger(event)
+ return flushPromises()
+}
+
+async function setValue (
+ el: DOMWrapper | VueWrapper,
+ value: any
+) {
+ el.setValue(value)
+ return flushPromises()
+}
+
+async function renderForm (options: {
+ props: Partial>
+ slotVars?: object
+ slotComponents?: any
+ slotTemplate: string
+}) {
+ const state = reactive({})
+ return await mountSuspended(UForm, {
+ props: {
+ state,
+ ...options.props
+ },
+ slots: {
+ default: {
+ // @ts-ignore
+ setup () {
+ return { state, ...options.slotVars }
+ },
+ components: {
+ UFormField,
+ ...options.slotComponents
+ },
+ template: options.slotTemplate
+ }
+ }
+ })
+}
+
+describe('Form', () => {
+ it.each([
+ ['basic case', { props: { state: {} } }],
+ ['with default slot', { props: { state: {} }, slots: { default: 'Form slot' } }]
+ ])('renders %s correctly', async (nameOrHtml: string, options: { props: FormProps }) => {
+ const html = await ComponentRender(nameOrHtml, options, UForm)
+ expect(html).toMatchSnapshot()
+ })
+
+ it.each([
+ ['zod', {
+ schema: z.object({
+ email: z.string(),
+ password: z.string().min(8, 'Must be at least 8 characters')
+ })
+ }
+ ],
+ ['yup', {
+ schema: yup.object({
+ email: yup.string(),
+ password: yup.string().min(8, 'Must be at least 8 characters')
+ })
+ }
+ ],
+ ['joi', {
+ schema: Joi.object({
+ email: Joi.string(),
+ password: Joi.string().min(8).messages({
+ 'string.min': 'Must be at least {#limit} characters'
+ })
+ })
+ }
+ ],
+ ['valibot', {
+ schema: valibot.objectAsync({
+ email: valibot.string(),
+ password: valibot.string([
+ valibot.minLength(8, 'Must be at least 8 characters')
+ ])
+ })
+ }
+ ],
+ ['custom', {
+ async validate (state: any) {
+ const errs = []
+ if (!state.email)
+ errs.push({ name: 'email', message: 'Email is required' })
+ if (state.password?.length < 8)
+ errs.push({
+ name: 'password',
+ message: 'Must be at least 8 characters'
+ })
+
+ return errs
+ }
+ }
+ ]
+ ])('%s validation works', async (_nameOrHtml: string, options: Partial>) => {
+ const wrapper = await renderForm({
+ props: options,
+ slotComponents: {
+ UFormField,
+ UInput
+ },
+ slotTemplate: `
+
+
+
+
+
+
+ `
+ })
+
+ const form = wrapper.find('form')
+ const emailInput = wrapper.find('#email')
+ const passwordInput = wrapper.find('#password')
+
+ await setValue(emailInput, 'bob@dylan.com')
+ await setValue(passwordInput, 'short')
+
+ await triggerEvent(form, 'submit.prevent')
+
+ // @ts-ignore
+ expect(wrapper.emitted('error')[0][0].errors).toMatchObject([
+ {
+ id: 'password',
+ name: 'password',
+ message: 'Must be at least 8 characters'
+ }
+ ])
+
+ expect(wrapper.html()).toMatchSnapshot('with error')
+
+ await setValue(passwordInput, 'validpassword')
+ await triggerEvent(form, 'submit.prevent')
+
+ expect(wrapper.emitted()).toHaveProperty('submit')
+ expect(wrapper.emitted('submit')![0][0]).toMatchObject({
+ data: { email: 'bob@dylan.com', password: 'validpassword' }
+ })
+
+ expect(wrapper.html()).toMatchSnapshot('without error')
+ })
+
+ it.each([
+ ['input', UInput, {}, 'foo']
+ // ['textarea', UTextarea, {}, 'foo']
+ ])('%s validate on blur works', async (_name, InputComponent, inputProps, validInputValue) => {
+ const wrapper = await renderForm({
+ props: {
+ validateOn: ['blur'],
+ async validate (state: any) {
+ if (!state.value)
+ return [{ name: 'value', message: 'Error message' }]
+ return []
+ }
+ },
+
+ slotVars: {
+ inputProps
+ },
+ slotComponents: { InputComponent },
+ slotTemplate: `
+
+
+
+ `
+ })
+
+ const input = wrapper.find('#input')
+ await triggerEvent(input, 'blur')
+ expect(wrapper.text()).toContain('Error message')
+
+ await setValue(input, validInputValue)
+ await triggerEvent(input, 'blur')
+ expect(wrapper.text()).not.toContain('Error message')
+ })
+
+ it.each([
+ // ['checkbox', UCheckbox, {}, true],
+ // ['range', URange, {}, 2],
+ // ['select', USelect, { options: ['Option 1', 'Option 2'] }, 'Option 2']
+ ])('%s validate on change works', async (_name, InputComponent, inputProps, validInputValue) => {
+ const wrapper = await renderForm({
+ props: {
+ validateOn: ['change'],
+ async validate (state: any) {
+ if (!state.value)
+ return [{ name: 'value', message: 'Error message' }]
+ return []
+ }
+ },
+
+ slotVars: {
+ inputProps
+ },
+ slotComponents: {
+ InputComponent
+ },
+ slotTemplate: `
+
+
+
+ `
+ })
+
+ const input = wrapper.find('#input')
+
+ await triggerEvent(input, 'change')
+ expect(wrapper.text()).toContain('Error message')
+
+ await setValue(input, validInputValue)
+ await triggerEvent(input, 'change')
+ expect(wrapper.text()).not.toContain('Error message')
+ })
+
+ // test('radio group validate on change works', async () => {
+ // const wrapper = await renderForm({
+ // props: {
+ // validateOn: ['change'],
+ // validate (state: any) {
+ // if (state.value !== 'Option 2')
+ // return [{ name: 'value', message: 'Error message' }]
+ // return []
+ // }
+ // },
+ // slotVars: {
+ // inputProps: {
+ // options: ['Option 1', 'Option 2', 'Option 3']
+ // }
+ // },
+ // slotComponents: {
+ // UFormField,
+ // URadioGroup
+ // },
+ // slotTemplate: `
+ //
+ //
+ //
+ // `
+ // })
+ //
+ // const option1 = wrapper.find('[value="Option 1"]')
+ // await setValue(option1, true)
+ // expect(wrapper.text()).toContain('Error message')
+ //
+ // const option2 = wrapper.find('[value="Option 2"]')
+ // await setValue(option2, true)
+ // expect(wrapper.text()).not.toContain('Error message')
+ // })
+ //
+ // test('radio validate on change works', async () => {
+ // const wrapper = await renderForm({
+ // props: {
+ // validateOn: ['change'],
+ // validate (state: any) {
+ // if (state.value !== 'Option 2')
+ // return [{ name: 'value', message: 'Error message' }]
+ // return []
+ // }
+ // },
+ // slotComponents: {
+ // UFormField,
+ // URadio
+ // },
+ // slotTemplate: `
+ //
+ //
+ //
+ //
+ // `
+ // })
+ //
+ // const option1 = wrapper.find('#option-1')
+ // await setValue(option1, true)
+ // expect(wrapper.text()).toContain('Error message')
+ //
+ // const option2 = wrapper.find('#option-2')
+ // await setValue(option2, true)
+ // expect(wrapper.text()).not.toContain('Error message')
+ // })
+ //
+ // test('toggle validate on change', async () => {
+ // const wrapper = await renderForm({
+ // props: {
+ // validateOn: ['change'],
+ // validate (state: any) {
+ // if (state.value) return [{ name: 'value', message: 'Error message' }]
+ // return []
+ // }
+ // },
+ // slotComponents: {
+ // UFormField,
+ // UToggle
+ // },
+ // slotTemplate: `
+ //
+ //
+ //
+ // `
+ // })
+ //
+ // const input = wrapper.findComponent({ name: 'Switch' })
+ // await setValue(input, true)
+ // expect(wrapper.text()).toContain('Error message')
+ //
+ // await setValue(input, false)
+ // expect(wrapper.text()).not.toContain('Error message')
+ // })
+ //
+ // test('select menu validate on change', async () => {
+ // const wrapper = await renderForm({
+ // props: {
+ // validateOn: ['change'],
+ // validate (state: any) {
+ // if (state.value !== 'Option 2')
+ // return [{ name: 'value', message: 'Error message' }]
+ // return []
+ // }
+ // },
+ // slotVars: {
+ // inputProps: {
+ // options: ['Option 1', 'Option 2', 'Option 3']
+ // }
+ // },
+ // slotComponents: {
+ // UFormField,
+ // USelectMenu
+ // },
+ // slotTemplate: `
+ //
+ //
+ //
+ // `
+ // })
+ //
+ // const input = wrapper.findComponent({ name: 'Listbox' })
+ // await setValue(input, 'Option 1')
+ // expect(wrapper.text()).toContain('Error message')
+ //
+ // await setValue(input, 'Option 2')
+ // expect(wrapper.text()).not.toContain('Error message')
+ // })
+ //
+ // test('input menu validate on change', async () => {
+ // const wrapper = await renderForm({
+ // props: {
+ // validateOn: ['change'],
+ // validate (state: any) {
+ // if (state.value !== 'Option 2')
+ // return [{ name: 'value', message: 'Error message' }]
+ // return []
+ // }
+ // },
+ // slotVars: {
+ // inputProps: {
+ // options: ['Option 1', 'Option 2', 'Option 3']
+ // }
+ // },
+ // slotComponents: {
+ // UFormField,
+ // UInputMenu
+ // },
+ // slotTemplate: `
+ //
+ //
+ //
+ // `
+ // })
+ //
+ // const input = wrapper.findComponent({ name: 'Combobox' })
+ // await setValue(input, 'Option 1')
+ // expect(wrapper.text()).toContain('Error message')
+ //
+ // await setValue(input, 'Option 2')
+ // expect(wrapper.text()).not.toContain('Error message')
+ // })
+ //
+
+ it.each([
+ ['input', UInput, {}, 'foo']
+ // ['textarea', UTextarea, {}, 'foo']
+ ])('%s validate on input works', async (_name, InputComponent, inputProps, validInputValue) => {
+ const wrapper = await renderForm({
+ props: {
+ validateOn: ['input', 'blur'],
+ async validate (state: any) {
+ if (!state.value)
+ return [{ name: 'value', message: 'Error message' }]
+ return []
+ }
+ },
+ slotVars: {
+ inputProps
+ },
+ slotComponents: {
+ UFormField,
+ InputComponent
+ },
+ slotTemplate: `
+
+
+
+ `
+ })
+
+ const input = wrapper.find('#input')
+
+ // Validation @input is enabled only after a blur event
+ await triggerEvent(input, 'blur')
+
+ expect(wrapper.text()).toContain('Error message')
+
+ await setValue(input, validInputValue)
+ // Waiting because of the debounced validation on input event.
+ await new Promise((r) => setTimeout(r, 50))
+ expect(wrapper.text()).not.toContain('Error message')
+ })
+})
diff --git a/test/components/FormField.spec.ts b/test/components/FormField.spec.ts
new file mode 100644
index 00000000..d47d8cb3
--- /dev/null
+++ b/test/components/FormField.spec.ts
@@ -0,0 +1,35 @@
+import { defineComponent } from 'vue'
+import { describe, it, expect } from 'vitest'
+import FormField, { type FormFieldProps } from '../../src/runtime/components/FormField.vue'
+import ComponentRender from '../component-render'
+
+// A wrapper component is needed here because of a conflict with the error prop / expose.
+// See: https://github.com/nuxt/test-utils/issues/684
+const FormFieldWrapper = defineComponent({
+ components: {
+ UFormField: FormField
+ },
+ template: ' '
+})
+
+describe('FormField', () => {
+ it.each([
+ ['with label and description', { props: { label: 'Username', description: 'Enter your username' } }],
+ ['with size', { props: { label: 'Username', description: 'Enter your username', size: 'xl' as const } }],
+ ['with required', { props: { label: 'Username', required: true } }],
+ ['with help', { props: { help: 'Username must be unique' } }],
+ ['with error', { props: { error: 'Username is already taken' } }],
+ ['with hint', { props: { hint: 'Use letters, numbers, and special characters' } }],
+ ['with class', { props: { class: 'relative' } }],
+ ['with ui', { props: { ui: { label: 'text-gray-900 dark:text-white' } } }],
+ ['with default slot', { slots: { default: () => 'Default slot' } }],
+ ['with label slot', { slots: { label: () => 'Label slot' } }],
+ ['with description slot', { slots: { description: () => 'Description slot' } }],
+ ['with error slot', { slots: { error: () => 'Error slot' } }],
+ ['with hint slot', { slots: { hint: () => 'Hint slot' } }],
+ ['with help slot', { slots: { help: () => 'Help slot' } }]
+ ])('renders %s correctly', async (nameOrHtml: string, options: { props?: FormFieldProps, slots?: any }) => {
+ const html = await ComponentRender(nameOrHtml, options, FormFieldWrapper)
+ expect(html).toMatchSnapshot()
+ })
+})
diff --git a/test/components/Input.spec.ts b/test/components/Input.spec.ts
new file mode 100644
index 00000000..f0386eb8
--- /dev/null
+++ b/test/components/Input.spec.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest'
+import Input, { type InputProps } from '../../src/runtime/components/Input.vue'
+import ComponentRender from '../component-render'
+
+describe('Input', () => {
+ it.each([
+ ['basic case', {}],
+ ['with name', { props: { name: 'username' } }],
+ ['with type', { props: { type: 'password' } }],
+ ['with placeholder', { props: { placeholder: 'Enter your username' } }],
+ ['with disabled', { props: { disabled: true } }],
+ ['with required', { props: { required: true } }],
+ // ['with icon', { props: { icon: 'i-heroicons-battery-50-solid' } }],
+ // ['with leading and icon', { props: { leading: true, icon: 'i-heroicons-battery-50-solid' } }],
+ // ['with leadingIcon', { props: { leadingIcon: 'i-heroicons-battery-50-solid' } }],
+ // ['with loading icon', { props: { loading: true } }],
+ // ['with leading slot', { slots: { leading: () => 'leading slot' } }],
+ // ['with trailing and icon', { props: { trailing: true, icon: 'i-heroicons-battery-50-solid' } }],
+ // ['with trailingIcon', { props: { trailingIcon: 'i-heroicons-battery-50-solid' } }],
+ // ['with trailing slot', { slots: { leading: () => 'trailing slot' } }],
+ ['with size', { props: { size: 'xs' as const } }],
+ ['with color', { props: { color: 'red' as const } }],
+ ['with variant', { props: { variant: 'outline' as const } }]
+ ])('renders %s correctly', async (nameOrHtml: string, options: { props?: InputProps, slots?: any }) => {
+ const html = await ComponentRender(nameOrHtml, options, Input)
+ expect(html).toMatchSnapshot()
+ })
+})
diff --git a/test/components/Tabs.spec.ts b/test/components/Tabs.spec.ts
index e6a6d2f4..ecf75c7b 100644
--- a/test/components/Tabs.spec.ts
+++ b/test/components/Tabs.spec.ts
@@ -23,7 +23,7 @@ describe('Tabs', () => {
['with defaultValue', { props: { items, defaultValue: '1' } }],
['with default slot', { props: { items }, slots: { default: () => 'Default slot' } }],
['with item slot', { props: { items }, slots: { item: () => 'Item slot' } }]
- ])('renders %s correctly', async (nameOrHtml: string, options: { props?: TabsProps, slots?: any }) => {
+ ])('renders %s correctly', async (nameOrHtml: string, options: { props?: TabsProps, slots?: any }) => {
const html = await ComponentRender(nameOrHtml, options, Tabs)
expect(html).toMatchSnapshot()
})
diff --git a/test/components/__snapshots__/Form.spec.ts.snap b/test/components/__snapshots__/Form.spec.ts.snap
new file mode 100644
index 00000000..62a9e095
--- /dev/null
+++ b/test/components/__snapshots__/Form.spec.ts.snap
@@ -0,0 +1,655 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Form > custom validation works > with error 1`] = `
+""
+`;
+
+exports[`Form > custom validation works > without error 1`] = `
+""
+`;
+
+exports[`Form > joi validation works > with error 1`] = `
+""
+`;
+
+exports[`Form > joi validation works > without error 1`] = `
+""
+`;
+
+exports[`Form > renders basic case correctly 1`] = `""`;
+
+exports[`Form > renders with default slot correctly 1`] = `""`;
+
+exports[`Form > valibot validation works > with error 1`] = `
+""
+`;
+
+exports[`Form > valibot validation works > without error 1`] = `
+""
+`;
+
+exports[`Form > yup validation works > with error 1`] = `
+""
+`;
+
+exports[`Form > yup validation works > without error 1`] = `
+""
+`;
+
+exports[`Form > zod validation works > with error 1`] = `
+""
+`;
+
+exports[`Form > zod validation works > without error 1`] = `
+""
+`;
diff --git a/test/components/__snapshots__/FormField.spec.ts.snap b/test/components/__snapshots__/FormField.spec.ts.snap
new file mode 100644
index 00000000..270607f7
--- /dev/null
+++ b/test/components/__snapshots__/FormField.spec.ts.snap
@@ -0,0 +1,202 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`FormField > renders with class correctly 1`] = `
+""
+`;
+
+exports[`FormField > renders with default slot correctly 1`] = `
+"
+
+
+
+
+
Default slot
+
+
+
+
"
+`;
+
+exports[`FormField > renders with description slot correctly 1`] = `
+""
+`;
+
+exports[`FormField > renders with error correctly 1`] = `
+"
+
+
+
+
+
+
+ Username is already taken
+
+
+
"
+`;
+
+exports[`FormField > renders with error slot correctly 1`] = `
+""
+`;
+
+exports[`FormField > renders with help correctly 1`] = `
+"
+
+
+
+
+
+
+ Username must be unique
+
+
+
"
+`;
+
+exports[`FormField > renders with help slot correctly 1`] = `
+""
+`;
+
+exports[`FormField > renders with hint correctly 1`] = `
+""
+`;
+
+exports[`FormField > renders with hint slot correctly 1`] = `
+""
+`;
+
+exports[`FormField > renders with label and description correctly 1`] = `
+"
+
+
+
+
+
Enter your username
+
+
+
+
+
+
+
"
+`;
+
+exports[`FormField > renders with label slot correctly 1`] = `
+""
+`;
+
+exports[`FormField > renders with required correctly 1`] = `
+""
+`;
+
+exports[`FormField > renders with size correctly 1`] = `
+"
+
+
+
+
+
Enter your username
+
+
+
+
+
+
+
"
+`;
+
+exports[`FormField > renders with ui correctly 1`] = `
+""
+`;
diff --git a/test/components/__snapshots__/Input.spec.ts.snap b/test/components/__snapshots__/Input.spec.ts.snap
new file mode 100644
index 00000000..370df1ba
--- /dev/null
+++ b/test/components/__snapshots__/Input.spec.ts.snap
@@ -0,0 +1,199 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Input > renders basic case correctly 1`] = `
+"
+
+
"
+`;
+
+exports[`Input > renders with color correctly 1`] = `
+"
+
+
"
+`;
+
+exports[`Input > renders with disabled correctly 1`] = `
+"
+
+
"
+`;
+
+exports[`Input > renders with name correctly 1`] = `
+"
+
+
"
+`;
+
+exports[`Input > renders with placeholder correctly 1`] = `
+"
+
+
"
+`;
+
+exports[`Input > renders with required correctly 1`] = `
+"
+
+
"
+`;
+
+exports[`Input > renders with size correctly 1`] = `
+"
+
+
"
+`;
+
+exports[`Input > renders with type correctly 1`] = `
+"
+
+
"
+`;
+
+exports[`Input > renders with variant correctly 1`] = `
+"
+
+
"
+`;