mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-03-11 17:48:44 -05:00
feat(frontend): add FormField component for form field abstraction
Introduces a reusable FormField component that abstracts Bulma's field/control/input pattern into a single component. This provides a consistent API for form fields and prepares the codebase for future CSS framework migrations. Features: - v-model binding for form values - label prop with automatic for attribute linking - error prop for validation messages - Default slot for custom inputs (selects, editors) - addon slot for button addons - Attribute passthrough via v-bind="$attrs" - Exposed value getter for browser autofill workarounds Includes 14 unit tests covering all functionality.
This commit is contained in:
141
frontend/src/components/input/FormField.test.ts
Normal file
141
frontend/src/components/input/FormField.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import FormField from './FormField.vue'
|
||||
|
||||
describe('FormField', () => {
|
||||
it('renders simple input', () => {
|
||||
const wrapper = mount(FormField)
|
||||
expect(wrapper.find('.field').exists()).toBe(true)
|
||||
expect(wrapper.find('.control').exists()).toBe(true)
|
||||
expect(wrapper.find('input.input').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('supports v-model binding', async () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {
|
||||
modelValue: 'initial',
|
||||
'onUpdate:modelValue': (val: string) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.element.value).toBe('initial')
|
||||
|
||||
await input.setValue('updated')
|
||||
expect(wrapper.props('modelValue')).toBe('updated')
|
||||
})
|
||||
|
||||
it('renders label when provided', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Username'},
|
||||
})
|
||||
const label = wrapper.find('label.label')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.text()).toBe('Username')
|
||||
})
|
||||
|
||||
it('does not render label when not provided', () => {
|
||||
const wrapper = mount(FormField)
|
||||
expect(wrapper.find('label.label').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('displays error message when provided', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {error: 'This field is required'},
|
||||
})
|
||||
const help = wrapper.find('.help.is-danger')
|
||||
expect(help.exists()).toBe(true)
|
||||
expect(help.text()).toBe('This field is required')
|
||||
})
|
||||
|
||||
it('does not display error message when error is null', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {error: null},
|
||||
})
|
||||
expect(wrapper.find('.help.is-danger').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not display error message when error is empty string', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {error: ''},
|
||||
})
|
||||
expect(wrapper.find('.help.is-danger').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders addon slot when provided', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
slots: {
|
||||
addon: '<button>Copy</button>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.field.has-addons').exists()).toBe(true)
|
||||
expect(wrapper.find('.control.is-expanded').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.control').length).toBe(2)
|
||||
expect(wrapper.find('button').text()).toBe('Copy')
|
||||
})
|
||||
|
||||
it('renders custom input via default slot', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Custom'},
|
||||
slots: {
|
||||
default: '<select><option>Option 1</option></select>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
expect(wrapper.find('select').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('passes attributes through to input', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
attrs: {
|
||||
type: 'email',
|
||||
placeholder: 'Enter email',
|
||||
disabled: true,
|
||||
readonly: true,
|
||||
autocomplete: 'email',
|
||||
},
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('type')).toBe('email')
|
||||
expect(input.attributes('placeholder')).toBe('Enter email')
|
||||
expect(input.attributes('disabled')).toBe('')
|
||||
expect(input.attributes('readonly')).toBe('')
|
||||
expect(input.attributes('autocomplete')).toBe('email')
|
||||
})
|
||||
|
||||
it('uses provided id for input', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {id: 'my-input', label: 'My Input'},
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
const label = wrapper.find('label')
|
||||
expect(input.attributes('id')).toBe('my-input')
|
||||
expect(label.attributes('for')).toBe('my-input')
|
||||
})
|
||||
|
||||
it('generates unique id when not provided', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'My Input'},
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
const label = wrapper.find('label')
|
||||
const inputId = input.attributes('id')
|
||||
expect(inputId).toBeTruthy()
|
||||
expect(label.attributes('for')).toBe(inputId)
|
||||
})
|
||||
|
||||
it('links label to input via for attribute', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Test Label'},
|
||||
})
|
||||
const label = wrapper.find('label')
|
||||
const input = wrapper.find('input')
|
||||
expect(label.attributes('for')).toBe(input.attributes('id'))
|
||||
})
|
||||
|
||||
it('exposes input value for direct access', async () => {
|
||||
const wrapper = mount(FormField)
|
||||
const input = wrapper.find('input')
|
||||
await input.setValue('test value')
|
||||
expect(wrapper.vm.value).toBe('test value')
|
||||
})
|
||||
})
|
||||
82
frontend/src/components/input/FormField.vue
Normal file
82
frontend/src/components/input/FormField.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, useSlots, useId, ref} from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number
|
||||
label?: string
|
||||
error?: string | null
|
||||
id?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const generatedId = useId()
|
||||
|
||||
const inputId = computed(() => props.id ?? generatedId)
|
||||
const hasAddon = computed(() => !!slots.addon)
|
||||
|
||||
const fieldClasses = computed(() => [
|
||||
'field',
|
||||
{'has-addons': hasAddon.value},
|
||||
])
|
||||
|
||||
const controlClasses = computed(() => [
|
||||
'control',
|
||||
{'is-expanded': hasAddon.value},
|
||||
])
|
||||
|
||||
// Expose input element for direct access (needed for browser autofill workarounds)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
defineExpose({
|
||||
get value() {
|
||||
return inputRef.value?.value ?? ''
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="fieldClasses">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
class="label"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div :class="controlClasses">
|
||||
<slot>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-bind="$attrs"
|
||||
:value="modelValue"
|
||||
class="input"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.addon"
|
||||
class="control"
|
||||
>
|
||||
<slot name="addon" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user