diff --git a/frontend/src/components/input/FormField.test.ts b/frontend/src/components/input/FormField.test.ts
index 8fe5f64ca..9e411e91c 100644
--- a/frontend/src/components/input/FormField.test.ts
+++ b/frontend/src/components/input/FormField.test.ts
@@ -14,7 +14,7 @@ describe('FormField', () => {
const wrapper = mount(FormField, {
props: {
modelValue: 'initial',
- 'onUpdate:modelValue': (val: string) => wrapper.setProps({modelValue: val}),
+ 'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
},
})
const input = wrapper.find('input')
@@ -199,4 +199,63 @@ describe('FormField', () => {
await input.setValue('test value')
expect(wrapper.vm.value).toBe('test value')
})
+
+ it('renders two-col layout with wrapping label', () => {
+ const wrapper = mount(FormField, {
+ props: {label: 'Name', layout: 'two-col'},
+ slots: {
+ default: '',
+ },
+ })
+ const label = wrapper.find('label.two-col')
+ expect(label.exists()).toBe(true)
+ expect(label.find('span').text()).toBe('Name')
+ expect(label.find('input.input').exists()).toBe(true)
+ })
+
+ it('two-col layout exposes id via slot scope', () => {
+ const wrapper = mount({
+ components: {FormField},
+ template: `
+
+
+
+ `,
+ })
+ expect(wrapper.find('input').attributes('id')).toBe('custom-id')
+ })
+
+ it('two-col layout omits the for attribute so implicit nesting labels any slotted control', () => {
+ const wrapper = mount(FormField, {
+ props: {label: 'Name', layout: 'two-col'},
+ slots: {
+ default: '',
+ },
+ })
+ const label = wrapper.find('label.two-col')
+ // for would point to a different id than the slotted control generates,
+ // so omit it entirely and rely on the label wrapping the control.
+ expect(label.attributes('for')).toBeUndefined()
+ expect(label.find('input').exists()).toBe(true)
+ })
+
+ it('renders the error message in two-col layout', () => {
+ const wrapper = mount(FormField, {
+ props: {label: 'Name', layout: 'two-col', error: 'Required'},
+ })
+ const help = wrapper.find('p.help.is-danger')
+ expect(help.exists()).toBe(true)
+ expect(help.text()).toBe('Required')
+ })
+
+ it('renders the addon slot in two-col layout', () => {
+ const wrapper = mount(FormField, {
+ props: {label: 'Name', layout: 'two-col'},
+ slots: {
+ addon: '',
+ },
+ })
+ expect(wrapper.find('.field.has-addons').exists()).toBe(true)
+ expect(wrapper.find('button').text()).toBe('Copy')
+ })
})
diff --git a/frontend/src/components/input/FormField.vue b/frontend/src/components/input/FormField.vue
index 6099cdc2b..ee23a61ce 100644
--- a/frontend/src/components/input/FormField.vue
+++ b/frontend/src/components/input/FormField.vue
@@ -8,16 +8,18 @@ interface Props {
id?: string
disabled?: boolean
loading?: boolean
+ layout?: 'stacked' | 'two-col'
}
-const props = defineProps()
+const props = withDefaults(defineProps(), {
+ layout: 'stacked',
+})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
}>()
function handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value
- // Preserve numeric type if modelValue was a number
if (typeof props.modelValue === 'number') {
emit('update:modelValue', value === '' ? '' : Number(value))
} else {
@@ -53,8 +55,6 @@ const inputClasses = computed(() => [
},
])
-// Only bind value when modelValue is explicitly provided (not undefined)
-// This allows the component to be used without v-model for native input behavior
const inputBindings = computed(() => {
const bindings: Record = {}
if (props.modelValue !== undefined) {
@@ -63,7 +63,6 @@ const inputBindings = computed(() => {
return bindings
})
-// Expose input element for direct access (needed for browser autofill workarounds)
const inputRef = ref(null)
defineExpose({
get value() {
@@ -77,34 +76,57 @@ defineExpose({
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+