refactor(frontend): support two-col layout on FormField

This commit is contained in:
kolaente 2026-04-17 14:36:31 +02:00 committed by kolaente
parent 59e7c9bce3
commit a6811a922a
2 changed files with 133 additions and 34 deletions

View File

@ -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: '<input class="input" />',
},
})
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: `
<FormField label="X" layout="two-col" id="custom-id" v-slot="{id}">
<input :id="id" />
</FormField>
`,
})
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: '<input id="some-generated-id" />',
},
})
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: '<button>Copy</button>',
},
})
expect(wrapper.find('.field.has-addons').exists()).toBe(true)
expect(wrapper.find('button').text()).toBe('Copy')
})
})

View File

@ -8,16 +8,18 @@ interface Props {
id?: string
disabled?: boolean
loading?: boolean
layout?: 'stacked' | 'two-col'
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
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<string, unknown> = {}
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<HTMLInputElement | null>(null)
defineExpose({
get value() {
@ -77,34 +76,57 @@ defineExpose({
<template>
<div :class="fieldClasses">
<label
v-if="label"
:for="inputId"
class="label"
>
{{ label }}
</label>
<div :class="controlClasses">
<slot :id="inputId">
<input
:id="inputId"
ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses"
:disabled="disabled || undefined"
@input="handleInput"
>
</slot>
</div>
<div
v-if="$slots.addon"
class="control"
>
<slot name="addon" />
</div>
<template v-if="layout === 'two-col'">
<label
v-if="label"
class="two-col"
>
<span>{{ label }}</span>
<slot :id="inputId">
<input
:id="inputId"
ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses"
:disabled="disabled || undefined"
@input="handleInput"
>
</slot>
</label>
<div
v-if="$slots.addon"
class="control"
>
<slot name="addon" />
</div>
</template>
<template v-else>
<label
v-if="label"
:for="inputId"
class="label"
>
{{ label }}
</label>
<div :class="controlClasses">
<slot :id="inputId">
<input
:id="inputId"
ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses"
:disabled="disabled || undefined"
@input="handleInput"
>
</slot>
</div>
<div
v-if="$slots.addon"
class="control"
>
<slot name="addon" />
</div>
</template>
<p
v-if="error"
class="help is-danger"
@ -113,3 +135,21 @@ defineExpose({
</p>
</div>
</template>
<style lang="scss" scoped>
label.two-col {
display: flex;
align-items: center;
gap: .5rem;
}
label.two-col > span,
label.two-col :deep(input),
label.two-col :deep(.input),
label.two-col :deep(.select),
label.two-col :deep(.timezone-select),
label.two-col :deep(.multiselect) {
flex: 0 0 50%;
box-sizing: border-box;
}
</style>