Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { FormError, FormSubmitEvent } from '@nuxt/ui'

const state = reactive({
email: undefined,
password: undefined,
name: undefined
})

const validate = (state: any): FormError[] => {
const errors = []
if (!state.name) errors.push({ name: 'name', message: 'Required' })
if (!state.email) errors.push({ name: 'email', message: 'Required' })
if (!state.password) errors.push({ name: 'password', message: 'Required' })
return errors
}

const toast = useToast()
async function onSubmit(event: FormSubmitEvent<typeof state>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
console.log(event.data)
}
</script>

<template>
<UForm :validate="validate" :state="state" label-position="left" class="space-y-4" @submit="onSubmit">
<UFormField label="Name" name="name">
<UInput v-model="state.name" />
</UFormField>

<UFormField label="Email" name="email">
<UInput v-model="state.email" />
</UFormField>

<UFormField label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormField>

<UButton type="submit">
Submit
</UButton>
</UForm>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { FormError, FormSubmitEvent } from '@nuxt/ui'

const state = reactive({
email: undefined,
password: undefined,
name: undefined
})

const validate = (state: any): FormError[] => {
const errors = []
if (!state.name) errors.push({ name: 'name', message: 'Required' })
if (!state.email) errors.push({ name: 'email', message: 'Required' })
if (!state.password) errors.push({ name: 'password', message: 'Required' })
return errors
}

const toast = useToast()
async function onSubmit(event: FormSubmitEvent<typeof state>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
console.log(event.data)
}
</script>

<template>
<UForm :validate="validate" :state="state" label-position="right" class="space-y-4" @submit="onSubmit">
<UFormField label="Name" name="name">
<UInput v-model="state.name" />
</UFormField>

<UFormField label="Email" name="email">
<UInput v-model="state.email" />
</UFormField>

<UFormField label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormField>

<UButton type="submit">
Submit
</UButton>
</UForm>
</template>
38 changes: 38 additions & 0 deletions docs/content/docs/2.components/form-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,44 @@ slots:
:u-input{placeholder="Enter your email" class="w-full"}
::

### Label Position

Use the `label-position` prop to control where the label appears relative to the form control. This overrides the position set by the parent Form component.

::component-code
---
prettier: true
ignore:
- label
props:
label: Email
labelPosition: left
slots:
default: |

<UInput placeholder="Enter your email" />
---

:u-input{placeholder="Enter your email"}
::

::component-code
---
prettier: true
ignore:
- label
props:
label: Email
labelPosition: right
slots:
default: |

<UInput placeholder="Enter your email" />
---

:u-input{placeholder="Enter your email"}
::

## API

### Props
Expand Down
26 changes: 26 additions & 0 deletions docs/content/docs/2.components/form.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,32 @@ name: 'form-example-nested-list'
---
::

### Horizontal Forms

Use the `label-position` prop to control the position of labels in form fields. You can set it to `left` for horizontal layouts with labels on the left, or `right` for labels on the right side.

::component-example
---
name: 'form-example-horizontal'
props:
class: 'w-80'
---
::

You can also use `right` positioned labels:

::component-example
---
name: 'form-example-right-label'
props:
class: 'w-80'
---
::

::tip
Individual FormField components can override the form's `label-position` by setting their own `label-position` prop.
::

## API

### Props
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,12 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"@vercel/speed-insights",
"better-sqlite3",
"puppeteer",
"sharp"
"sharp",
"unrs-resolver"
],
"ignoredBuiltDependencies": [
"@parcel/watcher",
Expand Down
57 changes: 56 additions & 1 deletion playgrounds/nuxt/app/pages/components/form-field.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@
{ help: 'Help! I need somebody!' },
{ required: true }
]

const state = reactive({
email: '',
name: '',
password: ''
})
</script>

<template>
<div class="flex flex-col items-center gap-4">
<div class="flex flex-col items-center gap-8">
<!-- Original examples -->
<div class="flex flex-col gap-4 ms-[-38px]">
<div v-for="(feedback, count) in feedbacks" :key="count" class="flex items-center">
<UFormField v-bind="feedback" label="Email" name="email">
Expand Down Expand Up @@ -46,5 +53,53 @@
<UInput placeholder="[email protected]" />
</UFormField>
</div>

<!-- Horizontal form examples -->
<div class="w-full max-w-md space-y-8">
<div>
<h3 class="text-lg font-semibold mb-4">Horizontal Form (Left Labels)</h3>

Check failure on line 60 in playgrounds/nuxt/app/pages/components/form-field.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Expected 1 line break before closing tag (`</h3>`), but no line breaks found

Check failure on line 60 in playgrounds/nuxt/app/pages/components/form-field.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Expected 1 line break after opening tag (`<h3>`), but no line breaks found
<UForm :state="state" label-position="left" class="space-y-4">
<UFormField label="Name" name="name">
<UInput v-model="state.name" placeholder="Enter your name" />
</UFormField>
<UFormField label="Email" name="email" description="We'll never share your email">
<UInput v-model="state.email" placeholder="Enter your email" />
</UFormField>
<UFormField label="Password" name="password" required>
<UInput v-model="state.password" type="password" placeholder="Enter password" />
</UFormField>
</UForm>
</div>

<div>
<h3 class="text-lg font-semibold mb-4">Horizontal Form (Right Labels)</h3>

Check failure on line 75 in playgrounds/nuxt/app/pages/components/form-field.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Expected 1 line break before closing tag (`</h3>`), but no line breaks found

Check failure on line 75 in playgrounds/nuxt/app/pages/components/form-field.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Expected 1 line break after opening tag (`<h3>`), but no line breaks found
<UForm :state="state" label-position="right" class="space-y-4">
<UFormField label="Name" name="name">
<UInput v-model="state.name" placeholder="Enter your name" />
</UFormField>
<UFormField label="Email" name="email" description="We'll never share your email">
<UInput v-model="state.email" placeholder="Enter your email" />
</UFormField>
<UFormField label="Password" name="password" required>
<UInput v-model="state.password" type="password" placeholder="Enter password" />
</UFormField>
</UForm>
</div>

<div>
<h3 class="text-lg font-semibold mb-4">Mixed Label Positions (Individual Override)</h3>

Check failure on line 90 in playgrounds/nuxt/app/pages/components/form-field.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Expected 1 line break before closing tag (`</h3>`), but no line breaks found

Check failure on line 90 in playgrounds/nuxt/app/pages/components/form-field.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Expected 1 line break after opening tag (`<h3>`), but no line breaks found
<UForm :state="state" label-position="top" class="space-y-4">
<UFormField label="Name" name="name" label-position="left">
<UInput v-model="state.name" placeholder="Enter your name" />
</UFormField>
<UFormField label="Email" name="email" label-position="right" description="We'll never share your email">
<UInput v-model="state.email" placeholder="Enter your email" />
</UFormField>
<UFormField label="Password" name="password" required>
<UInput v-model="state.password" type="password" placeholder="Enter password" />
</UFormField>
</UForm>
</div>
</div>
</div>
</template>
12 changes: 10 additions & 2 deletions src/runtime/components/Form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export interface FormProps<S extends FormSchema, T extends boolean = true> {
* @defaultValue `true`
*/
loadingAuto?: boolean
/**
* Position of the labels in form fields.
* @defaultValue 'top'
*/
labelPosition?: 'top' | 'left' | 'right'
class?: any
onSubmit?: ((event: FormSubmitEvent<FormData<S, T>>) => void | Promise<void>) | (() => void | Promise<void>)
}
Expand All @@ -67,7 +72,7 @@ export interface FormSlots {
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly, reactive } from 'vue'
import { useEventBus } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey, formErrorsInjectionKey } from '../composables/useFormField'
import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey, formErrorsInjectionKey, formLabelPositionInjectionKey } from '../composables/useFormField'
import { tv } from '../utils/tv'
import { validateSchema } from '../utils/form'
import { FormValidationException } from '../types/form'
Expand All @@ -82,7 +87,8 @@ const props = withDefaults(defineProps<FormProps<S, T>>(), {
validateOnInputDelay: 300,
attach: true,
transform: () => true as T,
loadingAuto: true
loadingAuto: true,
labelPosition: 'top'
})

const emits = defineEmits<FormEmits<S, T>>()
Expand Down Expand Up @@ -265,6 +271,8 @@ provide(formOptionsInjectionKey, computed(() => ({
validateOnInputDelay: props.validateOnInputDelay
})))

provide(formLabelPositionInjectionKey, computed(() => props.labelPosition))

defineExpose<Form<S>>({
validate: _validate,
errors,
Expand Down
13 changes: 11 additions & 2 deletions src/runtime/components/FormField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
* @defaultValue `300`
*/
validateOnInputDelay?: number
/**
* Position of the label. Overrides the Form's labelPosition.
* @defaultValue undefined (inherited from Form)
*/
labelPosition?: 'top' | 'left' | 'right'
class?: any
ui?: FormField['slots']
}
Expand All @@ -51,7 +56,7 @@
import type { Ref } from 'vue'
import { Primitive, Label } from 'reka-ui'
import { useAppConfig } from '#imports'
import { formFieldInjectionKey, inputIdInjectionKey, formErrorsInjectionKey } from '../composables/useFormField'
import { formFieldInjectionKey, inputIdInjectionKey, formErrorsInjectionKey, formLabelPositionInjectionKey } from '../composables/useFormField'
import { tv } from '../utils/tv'
import type { FormError, FormFieldInjectedOptions } from '../types/form'

Expand All @@ -60,9 +65,13 @@

const appConfig = useAppConfig() as FormField['AppConfig']

const labelPosition = inject(formLabelPositionInjectionKey, computed(() => 'top'))

Check failure on line 68 in src/runtime/components/FormField.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Duplicate key 'labelPosition'. May cause name collision in script or template tag
const effectiveLabelPosition = computed(() => props.labelPosition || labelPosition.value)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.formField || {}) })({
size: props.size,
required: props.required
required: props.required,
labelPosition: effectiveLabelPosition.value
}))

const formErrors = inject<Ref<FormError[]> | null>(formErrorsInjectionKey, null)
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/composables/useFormField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { inject, computed, provide } from 'vue'
import type { InjectionKey, Ref, ComputedRef } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import type { UseEventBusReturn } from '@vueuse/core'
import type { FormFieldProps } from '../types'
import type { FormErrorWithId, FormEvent, FormInputEvents, FormFieldInjectedOptions, FormInjectedOptions } from '../types/form'
import type { GetObjectField } from '../types/utils'

Expand All @@ -17,11 +16,12 @@ type Props<T> = {

export const formOptionsInjectionKey: InjectionKey<ComputedRef<FormInjectedOptions>> = Symbol('nuxt-ui.form-options')
export const formBusInjectionKey: InjectionKey<UseEventBusReturn<FormEvent<any>, string>> = Symbol('nuxt-ui.form-events')
export const formFieldInjectionKey: InjectionKey<ComputedRef<FormFieldInjectedOptions<FormFieldProps>> | undefined> = Symbol('nuxt-ui.form-field')
export const formFieldInjectionKey: InjectionKey<ComputedRef<FormFieldInjectedOptions<any>> | undefined> = Symbol('nuxt-ui.form-field')
export const inputIdInjectionKey: InjectionKey<Ref<string | undefined>> = Symbol('nuxt-ui.input-id')
export const formInputsInjectionKey: InjectionKey<Ref<Record<string, { id?: string, pattern?: RegExp }>>> = Symbol('nuxt-ui.form-inputs')
export const formLoadingInjectionKey: InjectionKey<Readonly<Ref<boolean>>> = Symbol('nuxt-ui.form-loading')
export const formErrorsInjectionKey: InjectionKey<Readonly<Ref<FormErrorWithId[]>>> = Symbol('nuxt-ui.form-errors')
export const formLabelPositionInjectionKey: InjectionKey<ComputedRef<'top' | 'left' | 'right'>> = Symbol('nuxt-ui.form-label-position')

export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, deferInputValidation?: boolean }) {
const formOptions = inject(formOptionsInjectionKey, undefined)
Expand Down
23 changes: 22 additions & 1 deletion src/theme/form-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,30 @@ export default {
true: {
label: `after:content-['*'] after:ms-0.5 after:text-error`
}
},
labelPosition: {
top: {
root: '',
wrapper: '',
labelWrapper: 'flex content-center items-center justify-between',
container: 'mt-1 relative'
},
left: {
root: 'flex items-start gap-4',
wrapper: 'min-w-0 flex-shrink-0',
labelWrapper: 'flex content-center items-center justify-between',
container: 'relative flex-1'
},
right: {
root: 'flex items-start gap-4 flex-row-reverse',
wrapper: 'min-w-0 flex-shrink-0',
labelWrapper: 'flex content-center items-center justify-between',
container: 'relative flex-1'
}
}
},
defaultVariants: {
size: 'md'
size: 'md',
labelPosition: 'top'
}
}
Loading