diff --git a/docs/app/components/content/examples/form/FormExampleHorizontal.vue b/docs/app/components/content/examples/form/FormExampleHorizontal.vue new file mode 100644 index 0000000000..185d0783a4 --- /dev/null +++ b/docs/app/components/content/examples/form/FormExampleHorizontal.vue @@ -0,0 +1,43 @@ + + + diff --git a/docs/app/components/content/examples/form/FormExampleRightLabel.vue b/docs/app/components/content/examples/form/FormExampleRightLabel.vue new file mode 100644 index 0000000000..f5bb1ddda8 --- /dev/null +++ b/docs/app/components/content/examples/form/FormExampleRightLabel.vue @@ -0,0 +1,43 @@ + + + diff --git a/docs/content/docs/2.components/form-field.md b/docs/content/docs/2.components/form-field.md index 5f2db7bd9c..e324d5cdba 100644 --- a/docs/content/docs/2.components/form-field.md +++ b/docs/content/docs/2.components/form-field.md @@ -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: | + + +--- + +:u-input{placeholder="Enter your email"} +:: + +::component-code +--- +prettier: true +ignore: + - label +props: + label: Email + labelPosition: right +slots: + default: | + + +--- + +:u-input{placeholder="Enter your email"} +:: + ## API ### Props diff --git a/docs/content/docs/2.components/form.md b/docs/content/docs/2.components/form.md index 626ad206ee..93fc38f134 100644 --- a/docs/content/docs/2.components/form.md +++ b/docs/content/docs/2.components/form.md @@ -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 diff --git a/package.json b/package.json index c6365e9d15..59c10c1989 100644 --- a/package.json +++ b/package.json @@ -217,9 +217,12 @@ }, "pnpm": { "onlyBuiltDependencies": [ + "@tailwindcss/oxide", + "@vercel/speed-insights", "better-sqlite3", "puppeteer", - "sharp" + "sharp", + "unrs-resolver" ], "ignoredBuiltDependencies": [ "@parcel/watcher", diff --git a/playgrounds/nuxt/app/pages/components/form-field.vue b/playgrounds/nuxt/app/pages/components/form-field.vue index 5dc1764bf6..2419676a4a 100644 --- a/playgrounds/nuxt/app/pages/components/form-field.vue +++ b/playgrounds/nuxt/app/pages/components/form-field.vue @@ -10,10 +10,17 @@ const feedbacks = [ { help: 'Help! I need somebody!' }, { required: true } ] + +const state = reactive({ + email: '', + name: '', + password: '' +}) diff --git a/src/runtime/components/Form.vue b/src/runtime/components/Form.vue index 72892888ce..67af45a055 100644 --- a/src/runtime/components/Form.vue +++ b/src/runtime/components/Form.vue @@ -49,6 +49,11 @@ export interface FormProps { * @defaultValue `true` */ loadingAuto?: boolean + /** + * Position of the labels in form fields. + * @defaultValue 'top' + */ + labelPosition?: 'top' | 'left' | 'right' class?: any onSubmit?: ((event: FormSubmitEvent>) => void | Promise) | (() => void | Promise) } @@ -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' @@ -82,7 +87,8 @@ const props = withDefaults(defineProps>(), { validateOnInputDelay: 300, attach: true, transform: () => true as T, - loadingAuto: true + loadingAuto: true, + labelPosition: 'top' }) const emits = defineEmits>() @@ -265,6 +271,8 @@ provide(formOptionsInjectionKey, computed(() => ({ validateOnInputDelay: props.validateOnInputDelay }))) +provide(formLabelPositionInjectionKey, computed(() => props.labelPosition)) + defineExpose>({ validate: _validate, errors, diff --git a/src/runtime/components/FormField.vue b/src/runtime/components/FormField.vue index 97519b588d..d16adc4cb9 100644 --- a/src/runtime/components/FormField.vue +++ b/src/runtime/components/FormField.vue @@ -32,6 +32,11 @@ export interface FormFieldProps { * @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'] } @@ -51,7 +56,7 @@ import { computed, ref, inject, provide, useId } from 'vue' 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' @@ -60,9 +65,13 @@ const slots = defineSlots() const appConfig = useAppConfig() as FormField['AppConfig'] +const labelPosition = inject(formLabelPositionInjectionKey, computed(() => 'top')) +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 | null>(formErrorsInjectionKey, null) diff --git a/src/runtime/composables/useFormField.ts b/src/runtime/composables/useFormField.ts index 5187e6b957..2036f0ccc9 100644 --- a/src/runtime/composables/useFormField.ts +++ b/src/runtime/composables/useFormField.ts @@ -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' @@ -17,11 +16,12 @@ type Props = { export const formOptionsInjectionKey: InjectionKey> = Symbol('nuxt-ui.form-options') export const formBusInjectionKey: InjectionKey, string>> = Symbol('nuxt-ui.form-events') -export const formFieldInjectionKey: InjectionKey> | undefined> = Symbol('nuxt-ui.form-field') +export const formFieldInjectionKey: InjectionKey> | undefined> = Symbol('nuxt-ui.form-field') export const inputIdInjectionKey: InjectionKey> = Symbol('nuxt-ui.input-id') export const formInputsInjectionKey: InjectionKey>> = Symbol('nuxt-ui.form-inputs') export const formLoadingInjectionKey: InjectionKey>> = Symbol('nuxt-ui.form-loading') export const formErrorsInjectionKey: InjectionKey>> = Symbol('nuxt-ui.form-errors') +export const formLabelPositionInjectionKey: InjectionKey> = Symbol('nuxt-ui.form-label-position') export function useFormField(props?: Props, opts?: { bind?: boolean, deferInputValidation?: boolean }) { const formOptions = inject(formOptionsInjectionKey, undefined) diff --git a/src/theme/form-field.ts b/src/theme/form-field.ts index b05281ab2f..0537de81a6 100644 --- a/src/theme/form-field.ts +++ b/src/theme/form-field.ts @@ -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' } }